Add Picasa module to new3d. It's self-contained and will be needed to implement PicasaMediaSet later.

Change-Id: I41b3839215366dee4997b9743e4ca62cf50df885
diff --git a/new3d/src/com/android/gallery3d/picasa/AlbumEntry.java b/new3d/src/com/android/gallery3d/picasa/AlbumEntry.java
new file mode 100644
index 0000000..24d6a40
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/AlbumEntry.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import org.xml.sax.Attributes;
+
+/**
+ * This class models the album entry kind in the Picasa GData API.
+ */
+@Entry.Table("albums")
+public final class AlbumEntry extends Entry {
+    public static final EntrySchema SCHEMA = new EntrySchema(AlbumEntry.class);
+
+    /**
+     * The user account that is the sync source for this entry. Must be set
+     * before insert/update.
+     */
+    @Column(Columns.SYNC_ACCOUNT)
+    public String syncAccount;
+
+    /**
+     * The ETag for the album/photos GData feed.
+     */
+    @Column(Columns.PHOTOS_ETAG)
+    public String photosEtag = null;
+
+    /**
+     * True if the contents of the album need to be synchronized. Must be set
+     * before insert/update.
+     */
+    @Column(Columns.PHOTOS_DIRTY)
+    public boolean photosDirty;
+
+    /**
+     * The "edit" URI of the album.
+     */
+    @Column(Columns.EDIT_URI)
+    public String editUri;
+
+    /**
+     * The album owner.
+     */
+    @Column(Columns.USER)
+    public String user;
+
+    /**
+     * The title of the album.
+     */
+    @Column(value = Columns.TITLE)
+    public String title;
+
+    /**
+     * A short summary of the contents of the album.
+     */
+    @Column(value = Columns.SUMMARY)
+    public String summary;
+
+    /**
+     * The date the album was created.
+     */
+    @Column(Columns.DATE_PUBLISHED)
+    public long datePublished;
+
+    /**
+     * The date the album was last updated.
+     */
+    @Column(Columns.DATE_UPDATED)
+    public long dateUpdated;
+
+    /**
+     * The date the album entry was last edited. May be more recent than
+     * dateUpdated.
+     */
+    @Column(Columns.DATE_EDITED)
+    public long dateEdited;
+
+    /**
+     * The number of photos in the album.
+     */
+    @Column(Columns.NUM_PHOTOS)
+    public int numPhotos;
+
+    /**
+     * The number of bytes of storage that this album uses.
+     */
+    @Column(Columns.BYTES_USED)
+    public long bytesUsed;
+
+    /**
+     * The user-specified location associated with the album.
+     */
+    @Column(Columns.LOCATION_STRING)
+    public String locationString;
+
+    /**
+     * The thumbnail URL associated with the album.
+     */
+    @Column(Columns.THUMBNAIL_URL)
+    public String thumbnailUrl;
+
+    /**
+     * A link to the HTML page associated with the album.
+     */
+    @Column(Columns.HTML_PAGE_URL)
+    public String htmlPageUrl;
+
+    /**
+     * Column names specific to album entries.
+     */
+    public static final class Columns extends PicasaApi.Columns {
+        public static final String PHOTOS_ETAG = "photos_etag";
+        public static final String USER = "user";
+        public static final String BYTES_USED = "bytes_used";
+        public static final String NUM_PHOTOS = "num_photos";
+        public static final String LOCATION_STRING = "location_string";
+        public static final String PHOTOS_DIRTY = "photos_dirty";
+    }
+
+    /**
+     * Resets values to defaults for object reuse.
+     */
+    @Override
+    public void clear() {
+        super.clear();
+        syncAccount = null;
+        photosDirty = false;
+        editUri = null;
+        user = null;
+        title = null;
+        summary = null;
+        datePublished = 0;
+        dateUpdated = 0;
+        dateEdited = 0;
+        numPhotos = 0;
+        bytesUsed = 0;
+        locationString = null;
+        thumbnailUrl = null;
+        htmlPageUrl = null;
+    }
+
+    /**
+     * Sets the property value corresponding to the given XML element, if
+     * applicable.
+     */
+    @Override
+    public void setPropertyFromXml(String uri, String localName, Attributes attrs, String content) {
+        char localNameChar = localName.charAt(0);
+        if (uri.equals(GDataParser.GPHOTO_NAMESPACE)) {
+            switch (localNameChar) {
+            case 'i':
+                if (localName.equals("id")) {
+                    id = Long.parseLong(content);
+                }
+                break;
+            case 'u':
+                if (localName.equals("user")) {
+                    user = content;
+                }
+                break;
+            case 'n':
+                if (localName.equals("numphotos")) {
+                    numPhotos = Integer.parseInt(content);
+                }
+                break;
+            case 'b':
+                if (localName.equals("bytesUsed")) {
+                    bytesUsed = Long.parseLong(content);
+                }
+                break;
+            }
+        } else if (uri.equals(GDataParser.ATOM_NAMESPACE)) {
+            switch (localNameChar) {
+            case 't':
+                if (localName.equals("title")) {
+                    title = content;
+                }
+                break;
+            case 's':
+                if (localName.equals("summary")) {
+                    summary = content;
+                }
+                break;
+            case 'p':
+                if (localName.equals("published")) {
+                    datePublished = GDataParser.parseAtomTimestamp(content);
+                }
+                break;
+            case 'u':
+                if (localName.equals("updated")) {
+                    dateUpdated = GDataParser.parseAtomTimestamp(content);
+                }
+                break;
+            case 'l':
+                if (localName.equals("link")) {
+                    String rel = attrs.getValue("", "rel");
+                    String href = attrs.getValue("", "href");
+                    if (rel.equals("alternate") && attrs.getValue("", "type").equals("text/html")) {
+                        htmlPageUrl = href;
+                    } else if (rel.equals("edit")) {
+                        editUri = href;
+                    }
+                }
+                break;
+            }
+        } else if (uri.equals(GDataParser.APP_NAMESPACE)) {
+            if (localName.equals("edited")) {
+                dateEdited = GDataParser.parseAtomTimestamp(content);
+            }
+        } else if (uri.equals(GDataParser.MEDIA_RSS_NAMESPACE)) {
+            if (localName == "thumbnail") {
+                thumbnailUrl = attrs.getValue("", "url");
+            }
+        }
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/Entry.java b/new3d/src/com/android/gallery3d/picasa/Entry.java
new file mode 100644
index 0000000..a20bad0
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/Entry.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import org.xml.sax.Attributes;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+public abstract class Entry {
+    public static final String[] ID_PROJECTION = { "_id" };
+
+    // The primary key of the entry.
+    @Column("_id")
+    public long id = 0;
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target(ElementType.TYPE)
+    public @interface Table {
+        String value();
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target(ElementType.FIELD)
+    public @interface Column {
+        String value();
+
+        boolean indexed() default false;
+
+        boolean fullText() default false;
+    }
+
+    public void clear() {
+        id = 0;
+    }
+
+    abstract public void setPropertyFromXml(
+            String uri, String localName, Attributes attrs, String content);
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/EntrySchema.java b/new3d/src/com/android/gallery3d/picasa/EntrySchema.java
new file mode 100644
index 0000000..b4993ee
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/EntrySchema.java
@@ -0,0 +1,423 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.util.Log;
+
+import com.android.gallery3d.ui.Util;
+
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+
+public final class EntrySchema {
+    public static final int TYPE_STRING = 0;
+    public static final int TYPE_BOOLEAN = 1;
+    public static final int TYPE_SHORT = 2;
+    public static final int TYPE_INT = 3;
+    public static final int TYPE_LONG = 4;
+    public static final int TYPE_FLOAT = 5;
+    public static final int TYPE_DOUBLE = 6;
+    public static final int TYPE_BLOB = 7;
+    public static final String SQLITE_TYPES[] = {
+            "TEXT", "INTEGER", "INTEGER", "INTEGER", "INTEGER", "REAL", "REAL", "NONE" };
+
+    private static final String TAG = "SchemaInfo";
+    private static final String FULL_TEXT_INDEX_SUFFIX = "_fulltext";
+
+    private final String mTableName;
+    private final ColumnInfo[] mColumnInfo;
+    private final String[] mProjection;
+    private final boolean mHasFullTextIndex;
+
+    public EntrySchema(Class<? extends Entry> clazz) {
+        // Get table and column metadata from reflection.
+        ColumnInfo[] columns = parseColumnInfo(clazz);
+        mTableName = parseTableName(clazz);
+        mColumnInfo = columns;
+
+        // Cache the list of projection columns and check for full-text columns.
+        String[] projection = {};
+        boolean hasFullTextIndex = false;
+        if (columns != null) {
+            projection = new String[columns.length];
+            for (int i = 0; i != columns.length; ++i) {
+                ColumnInfo column = columns[i];
+                projection[i] = column.name;
+                if (column.fullText) {
+                    hasFullTextIndex = true;
+                }
+            }
+        }
+        mProjection = projection;
+        mHasFullTextIndex = hasFullTextIndex;
+    }
+
+    public String getTableName() {
+        return mTableName;
+    }
+
+    public ColumnInfo[] getColumnInfo() {
+        return mColumnInfo;
+    }
+
+    public String[] getProjection() {
+        return mProjection;
+    }
+
+    private void logExecSql(SQLiteDatabase db, String sql) {
+        // Log.i(TAG, sql);
+        db.execSQL(sql);
+    }
+
+    public void cursorToObject(Cursor cursor, Entry object) {
+        try {
+            for (ColumnInfo column : mColumnInfo) {
+                int columnIndex = column.projectionIndex;
+                Field field = column.field;
+                switch (column.type) {
+                case TYPE_STRING:
+                    field.set(object, cursor.getString(columnIndex));
+                    break;
+                case TYPE_BOOLEAN:
+                    field.setBoolean(object, cursor.getShort(columnIndex) == 1);
+                    break;
+                case TYPE_SHORT:
+                    field.setShort(object, cursor.getShort(columnIndex));
+                    break;
+                case TYPE_INT:
+                    field.setInt(object, cursor.getInt(columnIndex));
+                    break;
+                case TYPE_LONG:
+                    field.setLong(object, cursor.getLong(columnIndex));
+                    break;
+                case TYPE_FLOAT:
+                    field.setFloat(object, cursor.getFloat(columnIndex));
+                    break;
+                case TYPE_DOUBLE:
+                    field.setDouble(object, cursor.getDouble(columnIndex));
+                    break;
+                case TYPE_BLOB:
+                    field.set(object, cursor.getBlob(columnIndex));
+                    break;
+                }
+            }
+        } catch (IllegalAccessException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public void objectToValues(Entry object, ContentValues values) {
+        try {
+            for (ColumnInfo column : mColumnInfo) {
+                String columnName = column.name;
+                Field field = column.field;
+                switch (column.type) {
+                case TYPE_STRING:
+                    values.put(columnName, (String) field.get(object));
+                    break;
+                case TYPE_BOOLEAN:
+                    values.put(columnName, field.getBoolean(object));
+                    break;
+                case TYPE_SHORT:
+                    values.put(columnName, field.getShort(object));
+                    break;
+                case TYPE_INT:
+                    values.put(columnName, field.getInt(object));
+                    break;
+                case TYPE_LONG:
+                    values.put(columnName, field.getLong(object));
+                    break;
+                case TYPE_FLOAT:
+                    values.put(columnName, field.getFloat(object));
+                    break;
+                case TYPE_DOUBLE:
+                    values.put(columnName, field.getDouble(object));
+                    break;
+                case TYPE_BLOB:
+                    values.put(columnName, (byte[]) field.get(object));
+                    break;
+                }
+            }
+        } catch (IllegalAccessException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public Cursor queryAll(SQLiteDatabase db) {
+        return db.query(mTableName, mProjection, null, null, null, null, null);
+    }
+
+    public boolean queryWithId(SQLiteDatabase db, long id, Entry entry) {
+        Cursor cursor = db.query(mTableName, mProjection, "_id=?",
+                new String[] {Long.toString(id)}, null, null, null);
+        boolean success = false;
+        if (cursor.moveToFirst()) {
+            cursorToObject(cursor, entry);
+            success = true;
+        }
+        cursor.close();
+        return success;
+    }
+
+    public long insertOrReplace(SQLiteDatabase db, Entry entry) {
+        ContentValues values = new ContentValues();
+        objectToValues(entry, values);
+        if (entry.id == 0) {
+            Log.i(TAG, "removing id before insert");
+            values.remove("_id");
+        }
+        long id = db.replace(mTableName, "_id", values);
+        entry.id = id;
+        return id;
+    }
+
+    public boolean deleteWithId(SQLiteDatabase db, long id) {
+        return db.delete(mTableName, "_id=?", new String[] { Long.toString(id) }) == 1;
+    }
+
+    public void createTables(SQLiteDatabase db) {
+        // Wrapped class must have a @Table.Definition.
+        String tableName = mTableName;
+        Util.Assert(tableName != null);
+
+        // Add the CREATE TABLE statement for the main table.
+        StringBuilder sql = new StringBuilder("CREATE TABLE ");
+        sql.append(tableName);
+        sql.append(" (_id INTEGER PRIMARY KEY");
+        for (ColumnInfo column : mColumnInfo) {
+            if (!column.isId()) {
+                sql.append(',');
+                sql.append(column.name);
+                sql.append(' ');
+                sql.append(SQLITE_TYPES[column.type]);
+                if (column.extraSql != null) {
+                    sql.append(' ');
+                    sql.append(column.extraSql);
+                }
+            }
+        }
+        sql.append(");");
+        logExecSql(db, sql.toString());
+        sql.setLength(0);
+
+        // Create indexes for all indexed columns.
+        for (ColumnInfo column : mColumnInfo) {
+            // Create an index on the indexed columns.
+            if (column.indexed) {
+                sql.append("CREATE INDEX ");
+                sql.append(tableName);
+                sql.append("_index_");
+                sql.append(column.name);
+                sql.append(" ON ");
+                sql.append(tableName);
+                sql.append(" (");
+                sql.append(column.name);
+                sql.append(");");
+                logExecSql(db, sql.toString());
+                sql.setLength(0);
+            }
+        }
+
+        if (mHasFullTextIndex) {
+            // Add an FTS virtual table if using full-text search.
+            String ftsTableName = tableName + FULL_TEXT_INDEX_SUFFIX;
+            sql.append("CREATE VIRTUAL TABLE ");
+            sql.append(ftsTableName);
+            sql.append(" USING FTS3 (_id INTEGER PRIMARY KEY");
+            for (ColumnInfo column : mColumnInfo) {
+                if (column.fullText) {
+                    // Add the column to the FTS table.
+                    String columnName = column.name;
+                    sql.append(',');
+                    sql.append(columnName);
+                    sql.append(" TEXT");
+                }
+            }
+            sql.append(");");
+            logExecSql(db, sql.toString());
+            sql.setLength(0);
+
+            // Build an insert statement that will automatically keep the FTS
+            // table in sync.
+            StringBuilder insertSql = new StringBuilder("INSERT OR REPLACE INTO ");
+            insertSql.append(ftsTableName);
+            insertSql.append(" (_id");
+            for (ColumnInfo column : mColumnInfo) {
+                if (column.fullText) {
+                    insertSql.append(',');
+                    insertSql.append(column.name);
+                }
+            }
+            insertSql.append(") VALUES (new._id");
+            for (ColumnInfo column : mColumnInfo) {
+                if (column.fullText) {
+                    insertSql.append(",new.");
+                    insertSql.append(column.name);
+                }
+            }
+            insertSql.append(");");
+            String insertSqlString = insertSql.toString();
+
+            // Add an insert trigger.
+            sql.append("CREATE TRIGGER ");
+            sql.append(tableName);
+            sql.append("_insert_trigger AFTER INSERT ON ");
+            sql.append(tableName);
+            sql.append(" FOR EACH ROW BEGIN ");
+            sql.append(insertSqlString);
+            sql.append("END;");
+            logExecSql(db, sql.toString());
+            sql.setLength(0);
+
+            // Add an update trigger.
+            sql.append("CREATE TRIGGER ");
+            sql.append(tableName);
+            sql.append("_update_trigger AFTER UPDATE ON ");
+            sql.append(tableName);
+            sql.append(" FOR EACH ROW BEGIN ");
+            sql.append(insertSqlString);
+            sql.append("END;");
+            logExecSql(db, sql.toString());
+            sql.setLength(0);
+
+            // Add a delete trigger.
+            sql.append("CREATE TRIGGER ");
+            sql.append(tableName);
+            sql.append("_delete_trigger AFTER DELETE ON ");
+            sql.append(tableName);
+            sql.append(" FOR EACH ROW BEGIN DELETE FROM ");
+            sql.append(ftsTableName);
+            sql.append(" WHERE _id = old._id; END;");
+            logExecSql(db, sql.toString());
+            sql.setLength(0);
+        }
+    }
+
+    public void dropTables(SQLiteDatabase db) {
+        String tableName = mTableName;
+        StringBuilder sql = new StringBuilder("DROP TABLE IF EXISTS ");
+        sql.append(tableName);
+        sql.append(';');
+        logExecSql(db, sql.toString());
+        sql.setLength(0);
+
+        if (mHasFullTextIndex) {
+            sql.append("DROP TABLE IF EXISTS ");
+            sql.append(tableName);
+            sql.append(FULL_TEXT_INDEX_SUFFIX);
+            sql.append(';');
+            logExecSql(db, sql.toString());
+        }
+
+    }
+
+    public void deleteAll(SQLiteDatabase db) {
+        StringBuilder sql = new StringBuilder("DELETE FROM ");
+        sql.append(mTableName);
+        sql.append(";");
+        logExecSql(db, sql.toString());
+    }
+
+    private String parseTableName(Class<? extends Object> clazz) {
+        // Check for a table annotation.
+        Entry.Table table = clazz.getAnnotation(Entry.Table.class);
+        if (table == null) {
+            return null;
+        }
+
+        // Return the table name.
+        return table.value();
+    }
+
+    private ColumnInfo[] parseColumnInfo(Class<? extends Object> clazz) {
+        // Gather metadata from each annotated field.
+        ArrayList<ColumnInfo> columns = new ArrayList<ColumnInfo>();
+        Field[] fields = clazz.getFields();
+        for (int i = 0; i != fields.length; ++i) {
+            // Get column metadata from the annotation.
+            Field field = fields[i];
+            Entry.Column info = ((AnnotatedElement) field).getAnnotation(Entry.Column.class);
+            if (info == null) {
+                continue;
+            }
+
+            // Determine the field type.
+            int type;
+            Class<?> fieldType = field.getType();
+            if (fieldType == String.class) {
+                type = TYPE_STRING;
+            } else if (fieldType == boolean.class) {
+                type = TYPE_BOOLEAN;
+            } else if (fieldType == short.class) {
+                type = TYPE_SHORT;
+            } else if (fieldType == int.class) {
+                type = TYPE_INT;
+            } else if (fieldType == long.class) {
+                type = TYPE_LONG;
+            } else if (fieldType == float.class) {
+                type = TYPE_FLOAT;
+            } else if (fieldType == double.class) {
+                type = TYPE_DOUBLE;
+            } else if (fieldType == byte[].class) {
+                type = TYPE_BLOB;
+            } else {
+                throw new IllegalArgumentException(
+                        "Unsupported field type for column: " + fieldType.getName());
+            }
+
+            // Add the column to the array.
+            int index = columns.size();
+            columns.add(new ColumnInfo(info.value(),
+                    type, info.indexed(), info.fullText(), field, index));
+        }
+
+        // Return a list.
+        ColumnInfo[] columnList = new ColumnInfo[columns.size()];
+        columns.toArray(columnList);
+        return columnList;
+    }
+
+    public static final class ColumnInfo {
+        private static final String ID_KEY = "_id";
+
+        public final String name;
+        public final int type;
+        public final boolean indexed;
+        public final boolean fullText;
+        public final String extraSql = "";
+        public final Field field;
+        public final int projectionIndex;
+
+        public ColumnInfo(String name, int type, boolean indexed,
+                boolean fullText, Field field, int projectionIndex) {
+            this.name = name.toLowerCase();
+            this.type = type;
+            this.indexed = indexed;
+            this.fullText = fullText;
+            this.field = field;
+            this.projectionIndex = projectionIndex;
+        }
+
+        public boolean isId() {
+            return ID_KEY.equals(name);
+        }
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/GDataClient.java b/new3d/src/com/android/gallery3d/picasa/GDataClient.java
new file mode 100644
index 0000000..9e8a47a
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/GDataClient.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.client.params.HttpClientParams;
+import org.apache.http.conn.scheme.PlainSocketFactory;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.conn.scheme.SchemeRegistry;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.entity.InputStreamEntity;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.params.HttpProtocolParams;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+public final class GDataClient {
+    private static final String TAG = "GDataClient";
+    private static final String USER_AGENT = "GData/1.0; gzip";
+    private static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override";
+    private static final String IF_MATCH = "If-Match";
+    private static final int CONNECTION_TIMEOUT = 20000; // ms.
+    private static final int MIN_GZIP_SIZE = 512;
+    public static final HttpParams HTTP_PARAMS;
+    public static final ThreadSafeClientConnManager HTTP_CONNECTION_MANAGER;
+
+    private final DefaultHttpClient mHttpClient =
+            new DefaultHttpClient(HTTP_CONNECTION_MANAGER, HTTP_PARAMS);
+    private String mAuthToken;
+
+    static {
+        // Prepare HTTP parameters.
+        HttpParams params = new BasicHttpParams();
+        HttpConnectionParams.setStaleCheckingEnabled(params, false);
+        HttpConnectionParams.setConnectionTimeout(params, CONNECTION_TIMEOUT);
+        HttpConnectionParams.setSoTimeout(params, CONNECTION_TIMEOUT);
+        HttpClientParams.setRedirecting(params, true);
+        HttpProtocolParams.setUserAgent(params, USER_AGENT);
+        HTTP_PARAMS = params;
+
+        // Register HTTP protocol.
+        SchemeRegistry schemeRegistry = new SchemeRegistry();
+        schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
+
+        // Create the connection manager.
+        HTTP_CONNECTION_MANAGER = new ThreadSafeClientConnManager(params, schemeRegistry);
+    }
+
+    public static final class Operation {
+        public String inOutEtag;
+        public int outStatus;
+        public InputStream outBody;
+    }
+
+    public void setAuthToken(String authToken) {
+        mAuthToken = authToken;
+    }
+
+    public void get(String feedUrl, Operation operation) throws IOException {
+        callMethod(new HttpGet(feedUrl), operation);
+    }
+
+    public void post(String feedUrl, byte[] data, String contentType, Operation operation)
+            throws IOException {
+        ByteArrayEntity entity = getCompressedEntity(data);
+        entity.setContentType(contentType);
+        HttpPost post = new HttpPost(feedUrl);
+        post.setEntity(entity);
+        callMethod(post, operation);
+    }
+
+    public void put(String feedUrl, byte[] data, String contentType, Operation operation)
+            throws IOException {
+        ByteArrayEntity entity = getCompressedEntity(data);
+        entity.setContentType(contentType);
+        HttpPost post = new HttpPost(feedUrl);
+        post.setHeader(X_HTTP_METHOD_OVERRIDE, "PUT");
+        post.setEntity(entity);
+        callMethod(post, operation);
+    }
+
+    public void putStream(String feedUrl, InputStream stream, String contentType,
+            Operation operation) throws IOException {
+        InputStreamEntity entity = new InputStreamEntity(stream, -1);
+        entity.setContentType(contentType);
+        HttpPost post = new HttpPost(feedUrl);
+        post.setHeader(X_HTTP_METHOD_OVERRIDE, "PUT");
+        post.setEntity(entity);
+        callMethod(post, operation);
+    }
+
+    public void delete(String feedUrl, Operation operation) throws IOException {
+        HttpPost post = new HttpPost(feedUrl);
+        String etag = operation.inOutEtag;
+        post.setHeader(X_HTTP_METHOD_OVERRIDE, "DELETE");
+        post.setHeader(IF_MATCH, etag != null ? etag : "*");
+        callMethod(post, operation);
+    }
+
+    private void callMethod(HttpUriRequest request, Operation operation) throws IOException {
+        // Specify GData protocol version 2.0.
+        request.addHeader("GData-Version", "2");
+
+        // Indicate support for gzip-compressed responses.
+        request.addHeader("Accept-Encoding", "gzip");
+
+        // Specify authorization token if provided.
+        String authToken = mAuthToken;
+        if (!TextUtils.isEmpty(authToken)) {
+            request.addHeader("Authorization", "GoogleLogin auth=" + authToken);
+        }
+
+        // Specify the ETag of a prior response, if available.
+        String etag = operation.inOutEtag;
+        if (etag != null) {
+            request.addHeader("If-None-Match", etag);
+        }
+
+        // Execute the HTTP request.
+        HttpResponse httpResponse = null;
+        try {
+            httpResponse = mHttpClient.execute(request);
+        } catch (IOException e) {
+            Log.w(TAG, "Request failed: " + request.getURI());
+            throw e;
+        }
+
+        // Get the status code and response body.
+        int status = httpResponse.getStatusLine().getStatusCode();
+        InputStream stream = null;
+        HttpEntity entity = httpResponse.getEntity();
+        if (entity != null) {
+            // Wrap the entity input stream in a GZIP decoder if necessary.
+            stream = entity.getContent();
+            if (stream != null) {
+                Header header = entity.getContentEncoding();
+                if (header != null) {
+                    if (header.getValue().contains("gzip")) {
+                        stream = new GZIPInputStream(stream);
+                    }
+                }
+            }
+        }
+
+        // Return the stream if successful.
+        Header etagHeader = httpResponse.getFirstHeader("ETag");
+        operation.outStatus = status;
+        operation.inOutEtag = etagHeader != null ? etagHeader.getValue() : null;
+        operation.outBody = stream;
+    }
+
+    private ByteArrayEntity getCompressedEntity(byte[] data) throws IOException {
+        ByteArrayEntity entity;
+        if (data.length >= MIN_GZIP_SIZE) {
+            ByteArrayOutputStream byteOutput = new ByteArrayOutputStream(data.length / 2);
+            GZIPOutputStream gzipOutput = new GZIPOutputStream(byteOutput);
+            gzipOutput.write(data);
+            gzipOutput.close();
+            entity = new ByteArrayEntity(byteOutput.toByteArray());
+        } else {
+            entity = new ByteArrayEntity(data);
+        }
+        return entity;
+    }
+
+    public static String inputStreamToString(InputStream stream) throws IOException {
+        InputStreamReader reader = new InputStreamReader(stream);
+        StringBuilder builder = new StringBuilder();
+        char buffer[] = new char[4096];
+        int charsRead = reader.read(buffer);
+        while (charsRead > 0) {
+            builder.append(buffer, 0, charsRead);
+        }
+        return builder.toString();
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/GDataParser.java b/new3d/src/com/android/gallery3d/picasa/GDataParser.java
new file mode 100644
index 0000000..175789c
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/GDataParser.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.text.format.Time;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.Locator;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.AttributesImpl;
+
+public final class GDataParser implements ContentHandler {
+    public static final String APP_NAMESPACE = "http://www.w3.org/2007/app";
+    public static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+    public static final String GD_NAMESPACE = "http://schemas.google.com/g/2005";
+    public static final String GPHOTO_NAMESPACE = "http://schemas.google.com/photos/2007";
+    public static final String MEDIA_RSS_NAMESPACE = "http://search.yahoo.com/mrss/";
+    public static final String GML_NAMESPACE = "http://www.opengis.net/gml";
+    private static final String FEED_ELEMENT = "feed";
+    private static final String ENTRY_ELEMENT = "entry";
+
+    private static final int STATE_DOCUMENT = 0;
+    private static final int STATE_FEED = 1;
+    private static final int STATE_ENTRY = 2;
+
+    private static final int NUM_LEVELS = 5;
+
+    private Entry mEntry = null;
+    private EntryHandler mHandler = null;
+    private int mState = STATE_DOCUMENT;
+    private int mLevel = 0;
+    private final String[] mUri = new String[NUM_LEVELS];
+    private final String[] mName = new String[NUM_LEVELS];
+    private final AttributesImpl[] mAttributes = new AttributesImpl[NUM_LEVELS];
+    private final StringBuilder mValue = new StringBuilder(128);
+
+    public interface EntryHandler {
+        void handleEntry(Entry entry);
+    }
+
+    public GDataParser() {
+        AttributesImpl[] attributes = mAttributes;
+        for (int i = 0; i != NUM_LEVELS; ++i) {
+            attributes[i] = new AttributesImpl();
+        }
+    }
+
+    public void setEntry(Entry entry) {
+        mEntry = entry;
+    }
+
+    public void setHandler(EntryHandler handler) {
+        mHandler = handler;
+    }
+
+    public static long parseAtomTimestamp(String timestamp) {
+        Time time = new Time();
+        time.parse3339(timestamp);
+        return time.toMillis(true);
+    }
+
+    public void startElement(String uri, String localName, String qName, Attributes attrs)
+            throws SAXException {
+        switch (mState) {
+            case STATE_DOCUMENT:
+                // Expect an atom:feed element.
+                if (uri.equals(ATOM_NAMESPACE) && localName.equals(FEED_ELEMENT)) {
+                    mState = STATE_FEED;
+                } else {
+                    throw new SAXException();
+                }
+                break;
+            case STATE_FEED:
+                // Expect a feed property element or an atom:entry element.
+                if (uri.equals(ATOM_NAMESPACE) && localName.equals(ENTRY_ELEMENT)) {
+                    mState = STATE_ENTRY;
+                    mEntry.clear();
+                } else {
+                    startProperty(uri, localName, attrs);
+                }
+                break;
+            case STATE_ENTRY:
+                startProperty(uri, localName, attrs);
+                break;
+        }
+    }
+
+    public void endElement(String uri, String localName, String qName) throws SAXException {
+        if (mLevel > 0) {
+            // Handle property exit.
+            endProperty();
+        } else {
+            // Handle state exit.
+            switch (mState) {
+            case STATE_DOCUMENT:
+                throw new SAXException();
+            case STATE_FEED:
+                mState = STATE_DOCUMENT;
+                break;
+            case STATE_ENTRY:
+                mState = STATE_FEED;
+                mHandler.handleEntry(mEntry);
+                break;
+            }
+        }
+    }
+
+    private void startProperty(String uri, String localName, Attributes attrs) {
+        // Push element information onto the property stack.
+        int level = ++mLevel;
+        mValue.setLength(0);
+        mUri[level] = uri;
+        mName[level] = localName;
+        mAttributes[level].setAttributes(attrs);
+    }
+
+    private void endProperty() {
+        // Apply property to the entry, then pop the stack.
+        int level = mLevel;
+        mEntry.setPropertyFromXml(mUri[level], mName[level], mAttributes[level], mValue.toString());
+        mLevel = level - 1;
+    }
+
+    public void characters(char[] ch, int start, int length) throws SAXException {
+        mValue.append(ch, start, length);
+    }
+
+    public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
+        // Ignored.
+    }
+
+    public void processingInstruction(String target, String data) throws SAXException {
+        // Ignored.
+    }
+
+    public void setDocumentLocator(Locator locator) {
+        // Ignored.
+    }
+
+    public void skippedEntity(String name) throws SAXException {
+        // Ignored.
+    }
+
+    public void startDocument() throws SAXException {
+        // Ignored.
+    }
+
+    public void endDocument() throws SAXException {
+        // Ignored.
+    }
+
+    public void startPrefixMapping(String prefix, String uri) throws SAXException {
+        // Ignored.
+    }
+
+    public void endPrefixMapping(String prefix) throws SAXException {
+        // Ignored.
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PhotoEntry.java b/new3d/src/com/android/gallery3d/picasa/PhotoEntry.java
new file mode 100644
index 0000000..e300a22
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PhotoEntry.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import org.xml.sax.Attributes;
+
+/**
+ * This class models the photo entry kind in the Picasa GData API.
+ */
+@Entry.Table("photos")
+public final class PhotoEntry extends Entry {
+    public static final EntrySchema SCHEMA = new EntrySchema(PhotoEntry.class);
+
+    /**
+     * The user account that is the sync source for this entry. Must be set
+     * before insert/update.
+     */
+    @Column("sync_account")
+    public String syncAccount;
+
+    /**
+     * The "edit" URI of the photo.
+     */
+    @Column("edit_uri")
+    public String editUri;
+
+    /**
+     * The containing album ID.
+     */
+    @Column(value = "album_id", indexed = true)
+    public long albumId;
+
+    /**
+     * The display index of the photo within the album. Must be set before
+     * insert/update.
+     */
+    @Column(value = "display_index", indexed = true)
+    public int displayIndex;
+
+    /**
+     * The title of the photo.
+     */
+    @Column("title")
+    public String title;
+
+    /**
+     * A short summary of the photo.
+     */
+    @Column("summary")
+    public String summary;
+
+    /**
+     * The date the photo was added.
+     */
+    @Column("date_published")
+    public long datePublished;
+
+    /**
+     * The date the photo was last updated.
+     */
+    @Column("date_updated")
+    public long dateUpdated;
+
+    /**
+     * The date the photo entry was last edited. May be more recent than
+     * dateUpdated.
+     */
+    @Column("date_edited")
+    public long dateEdited;
+
+    /**
+     * The date the photo was captured as specified in the EXIF data.
+     */
+    @Column("date_taken")
+    public long dateTaken;
+
+    /**
+     * The number of comments associated with the photo.
+     */
+    @Column("comment_count")
+    public int commentCount;
+
+    /**
+     * The width of the photo in pixels.
+     */
+    @Column("width")
+    public int width;
+
+    /**
+     * The height of the photo in pixels.
+     */
+    @Column("height")
+    public int height;
+
+    /**
+     * The rotation of the photo in degrees, if rotation has not already been
+     * applied.
+     */
+    @Column("rotation")
+    public int rotation;
+
+    /**
+     * The size of the photo is bytes.
+     */
+    @Column("size")
+    public int size;
+
+    /**
+     * The latitude associated with the photo.
+     */
+    @Column("latitude")
+    public double latitude;
+
+    /**
+     * The longitude associated with the photo.
+     */
+    @Column("longitude")
+    public double longitude;
+
+    /**
+     * The "mini-thumbnail" URL for the photo (currently 144px-cropped).
+     */
+    @Column("thumbnail_url")
+    public String thumbnailUrl;
+
+    /**
+     * The "screennail" URL for the photo (currently 800px).
+     */
+    @Column("screennail_url")
+    public String screennailUrl;
+
+    /**
+     * The "content" URL for the photo (currently 1280px, or a video). The
+     * original image URL is not fetched since "imgmax" accepts one size, used
+     * to get this resource.
+     */
+    @Column("content_url")
+    public String contentUrl;
+
+    /**
+     * The MIME type of the content URL.
+     */
+    @Column("content_type")
+    public String contentType;
+
+    /**
+     * A link to the HTML page associated with the album.
+     */
+    @Column("html_page_url")
+    public String htmlPageUrl;
+
+    /**
+     * Resets values to defaults for object reuse.
+     */
+    @Override
+    public void clear() {
+        super.clear();
+        syncAccount = null;
+        editUri = null;
+        albumId = 0;
+        displayIndex = 0;
+        title = null;
+        summary = null;
+        datePublished = 0;
+        dateUpdated = 0;
+        dateEdited = 0;
+        dateTaken = 0;
+        commentCount = 0;
+        width = 0;
+        height = 0;
+        rotation = 0;
+        size = 0;
+        latitude = 0;
+        longitude = 0;
+        thumbnailUrl = null;
+        screennailUrl = null;
+        contentUrl = null;
+        contentType = null;
+        htmlPageUrl = null;
+    }
+
+    /**
+     * Sets the property value corresponding to the given XML element, if
+     * applicable.
+     */
+    @Override
+    public void setPropertyFromXml(String uri, String localName, Attributes attrs, String content) {
+        try {
+            char localNameChar = localName.charAt(0);
+            if (uri.equals(GDataParser.GPHOTO_NAMESPACE)) {
+                switch (localNameChar) {
+                case 'i':
+                    if (localName.equals("id")) {
+                        id = Long.parseLong(content);
+                    }
+                    break;
+                case 'a':
+                    if (localName.equals("albumid")) {
+                        albumId = Long.parseLong(content);
+                    }
+                    break;
+                case 't':
+                    if (localName.equals("timestamp")) {
+                        dateTaken = Long.parseLong(content);
+                    }
+                    break;
+                case 'c':
+                    if (localName.equals("commentCount")) {
+                        commentCount = Integer.parseInt(content);
+                    }
+                    break;
+                case 'w':
+                    if (localName.equals("width")) {
+                        width = Integer.parseInt(content);
+                    }
+                    break;
+                case 'h':
+                    if (localName.equals("height")) {
+                        height = Integer.parseInt(content);
+                    }
+                    break;
+                case 'r':
+                    if (localName.equals("rotation")) {
+                        rotation = Integer.parseInt(content);
+                    }
+                    break;
+                case 's':
+                    if (localName.equals("size")) {
+                        size = Integer.parseInt(content);
+                    }
+                    break;
+                case 'l':
+                    if (localName.equals("latitude")) {
+                        latitude = Double.parseDouble(content);
+                    } else if (localName.equals("longitude")) {
+                        longitude = Double.parseDouble(content);
+                    }
+                    break;
+                }
+            } else if (uri.equals(GDataParser.ATOM_NAMESPACE)) {
+                switch (localNameChar) {
+                case 't':
+                    if (localName.equals("title")) {
+                        title = content;
+                    }
+                    break;
+                case 's':
+                    if (localName.equals("summary")) {
+                        summary = content;
+                    }
+                    break;
+                case 'p':
+                    if (localName.equals("published")) {
+                        datePublished = GDataParser.parseAtomTimestamp(content);
+                    }
+                    break;
+                case 'u':
+                    if (localName.equals("updated")) {
+                        dateUpdated = GDataParser.parseAtomTimestamp(content);
+                    }
+                    break;
+                case 'l':
+                    if (localName.equals("link")) {
+                        String rel = attrs.getValue("", "rel");
+                        String href = attrs.getValue("", "href");
+                        if (rel.equals("alternate")
+                                && attrs.getValue("", "type").equals("text/html")) {
+                            htmlPageUrl = href;
+                        } else if (rel.equals("edit")) {
+                            editUri = href;
+                        }
+                    }
+                    break;
+                }
+            } else if (uri.equals(GDataParser.APP_NAMESPACE)) {
+                if (localName.equals("edited")) {
+                    dateEdited = GDataParser.parseAtomTimestamp(content);
+                }
+            } else if (uri.equals(GDataParser.MEDIA_RSS_NAMESPACE)) {
+                if (localName.equals("thumbnail")) {
+                    int width = Integer.parseInt(attrs.getValue("", "width"));
+                    int height = Integer.parseInt(attrs.getValue("", "height"));
+                    int dimension = Math.max(width, height);
+                    String url = attrs.getValue("", "url");
+                    if (dimension <= 300) {
+                        thumbnailUrl = url;
+                    } else {
+                        screennailUrl = url;
+                    }
+                } else if (localName.equals("content")) {
+                    // Only replace an existing URL if the MIME type is video.
+                    String type = attrs.getValue("", "type");
+                    if (contentUrl == null || type.startsWith("video/")) {
+                        contentUrl = attrs.getValue("", "url");
+                        contentType = type;
+                    }
+                }
+            } else if (uri.equals(GDataParser.GML_NAMESPACE)) {
+                if (localName.equals("pos")) {
+                    int spaceIndex = content.indexOf(' ');
+                    if (spaceIndex != -1) {
+                        latitude = Double.parseDouble(content.substring(0, spaceIndex));
+                        longitude = Double.parseDouble(content.substring(spaceIndex + 1));
+                    }
+                }
+            }
+        } catch (Exception e) {
+            return;
+        }
+    }
+
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PicasaApi.java b/new3d/src/com/android/gallery3d/picasa/PicasaApi.java
new file mode 100644
index 0000000..011774e
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PicasaApi.java
@@ -0,0 +1,344 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.app.Activity;
+import android.content.Context;
+import android.content.SyncResult;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.util.Xml;
+
+import org.apache.http.HttpStatus;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+import java.net.SocketException;
+import java.util.ArrayList;
+
+public final class PicasaApi {
+    public static final int RESULT_OK = 0;
+    public static final int RESULT_NOT_MODIFIED = 1;
+    public static final int RESULT_ERROR = 2;
+
+    private static final String TAG = "PicasaAPI";
+    private static final String BASE_URL = "http://picasaweb.google.com/data/feed/api/";
+    private static final String BASE_QUERY_STRING;
+
+    static {
+        // Build the base query string using screen dimensions.
+        final StringBuilder query = new StringBuilder("?imgmax=1024&max-results=1000&thumbsize=");
+        final String thumbnailSize = "144u,";
+        final String screennailSize = "1024u";
+        query.append(thumbnailSize);
+        query.append(screennailSize);
+        query.append("&visibility=visible");
+        BASE_QUERY_STRING = query.toString();
+    }
+
+    private final GDataClient mClient;
+    private final GDataClient.Operation mOperation = new GDataClient.Operation();
+    private final GDataParser mParser = new GDataParser();
+    private final AlbumEntry mAlbumInstance = new AlbumEntry();
+    private final PhotoEntry mPhotoInstance = new PhotoEntry();
+    private AuthAccount mAuth;
+
+    public static final class AuthAccount {
+        public final String user;
+        public final String authToken;
+        public final Account account;
+
+        public AuthAccount(String user, String authToken, Account account) {
+            this.user = user;
+            this.authToken = authToken;
+            this.account = account;
+        }
+    }
+
+    public static Account[] getAccounts(Context context) {
+        // Return the list of accounts supporting the Picasa GData service.
+        AccountManager accountManager = AccountManager.get(context);
+        Account[] accounts = {};
+        try {
+            accounts = accountManager.getAccountsByTypeAndFeatures(PicasaService.ACCOUNT_TYPE,
+                    new String[] { PicasaService.FEATURE_SERVICE_NAME }, null, null).getResult();
+        } catch (Exception e) {
+            Log.e(TAG, "cannot get accounts", e);
+        }
+        return accounts;
+    }
+
+    public static AuthAccount[] getAuthenticatedAccounts(Context context) {
+        AccountManager accountManager = AccountManager.get(context);
+        Account[] accounts = getAccounts(context);
+        if (accounts == null) return new AuthAccount[0];
+
+        int numAccounts = accounts.length;
+        ArrayList<AuthAccount> authAccounts = new ArrayList<AuthAccount>(numAccounts);
+        for (int i = 0; i != numAccounts; ++i) {
+            Account account = accounts[i];
+            String authToken;
+            try {
+                // Get the token without user interaction.
+                authToken = accountManager
+                        .blockingGetAuthToken(account, PicasaService.SERVICE_NAME, true);
+
+                // TODO: Remove this once the build is signed by Google, since
+                // we will always have permission.
+                // This code requests permission from the user explicitly.
+                if (context instanceof Activity) {
+                    Bundle bundle = accountManager.getAuthToken(
+                            account, PicasaService.SERVICE_NAME, null, (Activity) context,
+                            null, null).getResult();
+                    authToken = bundle.getString("authtoken");
+                    PicasaService.requestSync(context, PicasaService.TYPE_USERS_ALBUMS, -1);
+                }
+
+                // Add the account information to the list of accounts.
+                if (authToken != null) {
+                    String username = canonicalizeUsername(account.name);
+                    authAccounts.add(new AuthAccount(username, authToken, account));
+                }
+            } catch (Exception e) {
+                Log.e(TAG, "fail to get authenticated accounts", e);
+            }
+        }
+        return authAccounts.toArray(new AuthAccount[authAccounts.size()]);
+    }
+
+    /**
+     * Returns a canonical username for a Gmail account.  Lowercases the username and
+     * strips off a "gmail.com" or "googlemail.com" domain, but leaves other domains alone.
+     *
+     * e.g., Passing in "User@gmail.com: will return "user".
+     *
+     * @param username The username to be canonicalized.
+     * @return The username, lowercased and possibly stripped of its domain if a "gmail.com" or
+     * "googlemail.com" domain.
+     */
+    public static String canonicalizeUsername(String username) {
+        username = username.toLowerCase();
+        if (username.contains("@gmail.") || username.contains("@googlemail.")) {
+            // Strip the domain from GMail accounts for
+            // canonicalization. TODO: is there an official way?
+            username = username.substring(0, username.indexOf('@'));
+        }
+        return username;
+    }
+
+    public PicasaApi() {
+        mClient = new GDataClient();
+    }
+
+    public void setAuth(AuthAccount auth) {
+        mAuth = auth;
+        synchronized (mClient) {
+            mClient.setAuthToken(auth.authToken);
+        }
+    }
+
+    @SuppressWarnings("fallthrough")
+    public int getAlbums(AccountManager accountManager,
+            SyncResult syncResult, UserEntry user, GDataParser.EntryHandler handler) {
+        // Construct the query URL for user albums.
+        StringBuilder builder = new StringBuilder(BASE_URL);
+        builder.append("user/");
+        builder.append(Uri.encode(mAuth.user));
+        builder.append(BASE_QUERY_STRING);
+        builder.append("&kind=album");
+        try {
+            // Send the request.
+            synchronized (mOperation) {
+                GDataClient.Operation operation = mOperation;
+                operation.inOutEtag = user.albumsEtag;
+                boolean retry = false;
+                int numRetries = 1;
+                do {
+                    retry = false;
+                    synchronized (mClient) {
+                        mClient.get(builder.toString(), operation);
+                    }
+                    switch (operation.outStatus) {
+                    case HttpStatus.SC_OK:
+                        break;
+                    case HttpStatus.SC_NOT_MODIFIED:
+                        return RESULT_NOT_MODIFIED;
+                    case HttpStatus.SC_FORBIDDEN:
+                    case HttpStatus.SC_UNAUTHORIZED:
+                        if (!retry) {
+                            accountManager.invalidateAuthToken(
+                                    PicasaService.ACCOUNT_TYPE, mAuth.authToken);
+                            retry = true;
+                        }
+                        if (numRetries == 0) {
+                            ++syncResult.stats.numAuthExceptions;
+                        }
+                        // fall-through
+                    default:
+                        Log.i(TAG, "getAlbums uri " + builder.toString());
+                        Log.e(TAG, "getAlbums: unexpected status code "
+                                + operation.outStatus + " data: " + operation.outBody.toString());
+                        ++syncResult.stats.numIoExceptions;
+                        return RESULT_ERROR;
+                    }
+                    --numRetries;
+                } while (retry && numRetries >= 0);
+
+                // Store the new ETag for the user/albums feed.
+                user.albumsEtag = operation.inOutEtag;
+
+                // Parse the response.
+                synchronized (mParser) {
+                    GDataParser parser = mParser;
+                    parser.setEntry(mAlbumInstance);
+                    parser.setHandler(handler);
+                    try {
+                        Xml.parse(operation.outBody, Xml.Encoding.UTF_8, parser);
+                    } catch (SocketException e) {
+                        Log.e(TAG, "getAlbumPhotos: " + e);
+                        ++syncResult.stats.numIoExceptions;
+                        e.printStackTrace();
+                        return RESULT_ERROR;
+                    }
+                }
+            }
+            return RESULT_OK;
+        } catch (IOException e) {
+            Log.e(TAG, "getAlbums: " + e);
+            ++syncResult.stats.numIoExceptions;
+        } catch (SAXException e) {
+            Log.e(TAG, "getAlbums: " + e);
+            ++syncResult.stats.numParseExceptions;
+        }
+        return RESULT_ERROR;
+    }
+
+    public int getAlbumPhotos(AccountManager accountManager, SyncResult syncResult,
+            AlbumEntry album, GDataParser.EntryHandler handler) {
+        // Construct the query URL for user albums.
+        StringBuilder builder = new StringBuilder(BASE_URL);
+        builder.append("user/");
+        builder.append(Uri.encode(mAuth.user));
+        builder.append("/albumid/");
+        builder.append(album.id);
+        builder.append(BASE_QUERY_STRING);
+        builder.append("&kind=photo");
+        try {
+            // Send the request.
+            synchronized (mOperation) {
+                GDataClient.Operation operation = mOperation;
+                operation.inOutEtag = album.photosEtag;
+                boolean retry = false;
+                int numRetries = 1;
+                do {
+                    retry = false;
+                    synchronized (mClient) {
+                        mClient.get(builder.toString(), operation);
+                    }
+                    switch (operation.outStatus) {
+                    case HttpStatus.SC_OK:
+                        break;
+                    case HttpStatus.SC_NOT_MODIFIED:
+                        return RESULT_NOT_MODIFIED;
+                    case HttpStatus.SC_FORBIDDEN:
+                    case HttpStatus.SC_UNAUTHORIZED:
+                        // We need to reset the authtoken and retry only once.
+                        if (!retry) {
+                            retry = true;
+                            accountManager.invalidateAuthToken(PicasaService.SERVICE_NAME, mAuth.authToken);
+                        }
+                        if (numRetries == 0) {
+                            ++syncResult.stats.numAuthExceptions;
+                        }
+                        break;
+                    default:
+                        Log.e(TAG, "getAlbumPhotos: " + builder.toString()
+                                + ", unexpected status code " + operation.outStatus);
+                        ++syncResult.stats.numIoExceptions;
+                        return RESULT_ERROR;
+                    }
+                    --numRetries;
+                } while (retry && numRetries >= 0);
+
+                // Store the new ETag for the album/photos feed.
+                album.photosEtag = operation.inOutEtag;
+
+                // Parse the response.
+                synchronized (mParser) {
+                    GDataParser parser = mParser;
+                    parser.setEntry(mPhotoInstance);
+                    parser.setHandler(handler);
+                    try {
+                        Xml.parse(operation.outBody, Xml.Encoding.UTF_8, parser);
+                    } catch (SocketException e) {
+                        Log.e(TAG, "getAlbumPhotos: " + e);
+                        ++syncResult.stats.numIoExceptions;
+                        e.printStackTrace();
+                        return RESULT_ERROR;
+                    }
+                }
+            }
+            return RESULT_OK;
+        } catch (IOException e) {
+            Log.e(TAG, "getAlbumPhotos", e);
+            ++syncResult.stats.numIoExceptions;
+        } catch (SAXException e) {
+            Log.e(TAG, "getAlbumPhotos", e);
+            ++syncResult.stats.numParseExceptions;
+        }
+        return RESULT_ERROR;
+    }
+
+    public int deleteEntry(String editUri) {
+        try {
+            synchronized (mOperation) {
+                GDataClient.Operation operation = mOperation;
+                operation.inOutEtag = null;
+                synchronized (mClient) {
+                    mClient.delete(editUri, operation);
+                }
+                if (operation.outStatus == 200) {
+                    return RESULT_OK;
+                } else {
+                    Log.e(TAG, "deleteEntry: failed with status code " + operation.outStatus);
+                }
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "deleteEntry", e);
+        }
+        return RESULT_ERROR;
+    }
+
+    /**
+     * Column names shared by multiple entry kinds.
+     */
+    public static class Columns {
+        public static final String _ID = "_id";
+        public static final String SYNC_ACCOUNT = "sync_account";
+        public static final String EDIT_URI = "edit_uri";
+        public static final String TITLE = "title";
+        public static final String SUMMARY = "summary";
+        public static final String DATE_PUBLISHED = "date_published";
+        public static final String DATE_UPDATED = "date_updated";
+        public static final String DATE_EDITED = "date_edited";
+        public static final String THUMBNAIL_URL = "thumbnail_url";
+        public static final String HTML_PAGE_URL = "html_page_url";
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PicasaContentProvider.java b/new3d/src/com/android/gallery3d/picasa/PicasaContentProvider.java
new file mode 100644
index 0000000..57b8295
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PicasaContentProvider.java
@@ -0,0 +1,603 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SyncResult;
+import android.content.pm.ProviderInfo;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public final class PicasaContentProvider extends TableContentProvider {
+    public static final String AUTHORITY = "com.android.gallery3d.picasa.contentprovider";
+    public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY);
+    public static final Uri PHOTOS_URI = Uri.withAppendedPath(BASE_URI, "photos");
+    public static final Uri ALBUMS_URI = Uri.withAppendedPath(BASE_URI, "albums");
+
+    private static final String TAG = "PicasaContentProvider";
+    private static final String[] ID_EDITED_PROJECTION = {"_id", "date_edited"};
+    private static final String[] ID_EDITED_INDEX_PROJECTION =
+            {"_id", "date_edited", "display_index"};
+    private static final String WHERE_ACCOUNT = "sync_account=?";
+    private static final String WHERE_ALBUM_ID = "album_id=?";
+
+    private final PhotoEntry mPhotoInstance = new PhotoEntry();
+    private final AlbumEntry mAlbumInstance = new AlbumEntry();
+    private SyncContext mSyncContext = null;
+    private Account mActiveAccount;
+
+    @Override
+    public void attachInfo(Context context, ProviderInfo info) {
+        // Initialize the provider and set the database.
+        super.attachInfo(context, info);
+        setDatabase(new Database(context, Database.DATABASE_NAME));
+
+        // Add mappings for each of the exposed tables.
+        addMapping(AUTHORITY, "photos", "vnd.android.gallery3d.picasa.photo", PhotoEntry.SCHEMA);
+        addMapping(AUTHORITY, "albums", "vnd.android.gallery3d.picasa.album", AlbumEntry.SCHEMA);
+
+        // Create the sync context.
+        try {
+            mSyncContext = new SyncContext();
+        } catch (Exception e) {
+            Log.e(TAG, "cannot get sync context", e);
+            // The database wasn't created successfully, we create a memory backed database.
+            setDatabase(new Database(context, null));
+        }
+    }
+
+    public static final class Database extends SQLiteOpenHelper {
+        public static final String DATABASE_NAME = "picasa.db";
+        public static final int DATABASE_VERSION = 83;
+
+        public Database(Context context, String name) {
+            super(context, name, null, DATABASE_VERSION);
+        }
+
+        @Override
+        public void onCreate(SQLiteDatabase db) {
+            PhotoEntry.SCHEMA.createTables(db);
+            AlbumEntry.SCHEMA.createTables(db);
+            UserEntry.SCHEMA.createTables(db);
+        }
+
+        @Override
+        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+            // No new versions yet, if we are asked to upgrade we just reset
+            // everything.
+            PhotoEntry.SCHEMA.dropTables(db);
+            AlbumEntry.SCHEMA.dropTables(db);
+            UserEntry.SCHEMA.dropTables(db);
+            onCreate(db);
+        }
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        // Ensure that the URI is well-formed. We currently do not allow WHERE
+        // clauses.
+        List<String> path = uri.getPathSegments();
+        if (path.size() != 2 || !uri.getAuthority().equals(AUTHORITY) || selection != null) {
+            return 0;
+        }
+
+        // Get the sync context.
+        SyncContext context = mSyncContext;
+
+        // Determine if the URI refers to an album or photo.
+        String type = path.get(0);
+        long id = Long.parseLong(path.get(1));
+        SQLiteDatabase db = context.db;
+        if (type.equals("photos")) {
+            // Retrieve the photo from the database to get the edit URI.
+            PhotoEntry photo = mPhotoInstance;
+            if (PhotoEntry.SCHEMA.queryWithId(db, id, photo)) {
+                // Send a DELETE request to the API.
+                if (context.login(photo.syncAccount)) {
+                    if (context.api.deleteEntry(photo.editUri) == PicasaApi.RESULT_OK) {
+                        deletePhoto(db, id);
+                        context.photosChanged = true;
+                        return 1;
+                    }
+                }
+            }
+        } else if (type.equals("albums")) {
+            // Retrieve the album from the database to get the edit URI.
+            AlbumEntry album = mAlbumInstance;
+            if (AlbumEntry.SCHEMA.queryWithId(db, id, album)) {
+                // Send a DELETE request to the API.
+                if (context.login(album.syncAccount)) {
+                    if (context.api.deleteEntry(album.editUri) == PicasaApi.RESULT_OK) {
+                        deleteAlbum(db, id);
+                        context.albumsChanged = true;
+                        return 1;
+                    }
+                }
+            }
+        }
+        context.finish();
+        return 0;
+    }
+
+    public void reloadAccounts() {
+        mSyncContext.reloadAccounts();
+    }
+
+    public void setActiveSyncAccount(Account account) {
+        mActiveAccount = account;
+    }
+
+    public void syncUsers(SyncResult syncResult) {
+        syncUsers(mSyncContext, syncResult);
+    }
+
+    public void syncUsersAndAlbums(final boolean syncAlbumPhotos, SyncResult syncResult) {
+        SyncContext context = mSyncContext;
+
+        // Synchronize users authenticated on the device.
+        UserEntry[] users = syncUsers(context, syncResult);
+
+        // Synchronize albums for each user.
+        String activeUsername = null;
+        if (mActiveAccount != null) {
+            activeUsername = PicasaApi.canonicalizeUsername(mActiveAccount.name);
+        }
+        boolean didSyncActiveUserName = false;
+        for (int i = 0, numUsers = users.length; i != numUsers; ++i) {
+            if (activeUsername != null && !context.accounts[i].user.equals(activeUsername))
+                continue;
+            if (!ContentResolver.getSyncAutomatically(context.accounts[i].account, AUTHORITY))
+                continue;
+            didSyncActiveUserName = true;
+            context.api.setAuth(context.accounts[i]);
+            syncUserAlbums(context, users[i], syncResult);
+            if (syncAlbumPhotos) {
+                syncUserPhotos(context, users[i].account, syncResult);
+            } else {
+                // // Always sync added albums.
+                // for (Long albumId : context.albumsAdded) {
+                // syncAlbumPhotos(albumId, false);
+                // }
+            }
+        }
+        if (!didSyncActiveUserName) {
+            ++syncResult.stats.numAuthExceptions;
+        }
+        context.finish();
+    }
+
+    public void syncAlbumPhotos(
+            final long albumId, final boolean forceRefresh, SyncResult syncResult) {
+        SyncContext context = mSyncContext;
+        AlbumEntry album = new AlbumEntry();
+        if (AlbumEntry.SCHEMA.queryWithId(context.db, albumId, album)) {
+            if ((album.photosDirty || forceRefresh) && context.login(album.syncAccount)) {
+                if (isSyncEnabled(album.syncAccount, context)) {
+                    syncAlbumPhotos(context, album.syncAccount, album, syncResult);
+                }
+            }
+        }
+        context.finish();
+    }
+
+    public static boolean isSyncEnabled(String accountName, SyncContext context) {
+        if (context.accounts == null) {
+            context.reloadAccounts();
+        }
+        PicasaApi.AuthAccount[] accounts = context.accounts;
+        int numAccounts = accounts.length;
+        for (int i = 0; i < numAccounts; ++i) {
+            PicasaApi.AuthAccount account = accounts[i];
+            if (account.user.equals(accountName)) {
+                return ContentResolver.getSyncAutomatically(account.account, AUTHORITY);
+            }
+        }
+        return true;
+    }
+
+    private UserEntry[] syncUsers(SyncContext context, SyncResult syncResult) {
+        // Get authorized accounts.
+        context.reloadAccounts();
+        PicasaApi.AuthAccount[] accounts = context.accounts;
+        int numUsers = accounts.length;
+        UserEntry[] users = new UserEntry[numUsers];
+
+        // Scan existing accounts.
+        EntrySchema schema = UserEntry.SCHEMA;
+        SQLiteDatabase db = context.db;
+        Cursor cursor = schema.queryAll(db);
+        if (cursor.moveToFirst()) {
+            do {
+                // Read the current account.
+                UserEntry entry = new UserEntry();
+                schema.cursorToObject(cursor, entry);
+
+                // Find the corresponding account, or delete the row if it does
+                // not exist.
+                int i;
+                for (i = 0; i != numUsers; ++i) {
+                    if (accounts[i].user.equals(entry.account)) {
+                        users[i] = entry;
+                        break;
+                    }
+                }
+                if (i == numUsers) {
+                    Log.e(TAG, "Deleting user " + entry.account);
+                    entry.albumsEtag = null;
+                    deleteUser(db, entry.account);
+                }
+            } while (cursor.moveToNext());
+        } else {
+            // Log.i(TAG, "No users in database yet");
+        }
+        cursor.close();
+
+        // Add new accounts and synchronize user albums if recursive.
+        for (int i = 0; i != numUsers; ++i) {
+            UserEntry entry = users[i];
+            PicasaApi.AuthAccount account = accounts[i];
+            if (entry == null) {
+                entry = new UserEntry();
+                entry.account = account.user;
+                users[i] = entry;
+                Log.e(TAG, "Inserting user " + entry.account);
+            }
+        }
+        return users;
+    }
+
+    private void syncUserAlbums(
+            final SyncContext context, final UserEntry user, final SyncResult syncResult) {
+        // Query existing album entry (id, dateEdited) sorted by ID.
+        final SQLiteDatabase db = context.db;
+        Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(),
+                ID_EDITED_PROJECTION, WHERE_ACCOUNT, new String[] { user.account },
+                null, null, AlbumEntry.Columns.DATE_EDITED);
+        int localCount = cursor.getCount();
+
+        // Build a sorted index with existing entry timestamps.
+        final EntryMetadata local[] = new EntryMetadata[localCount];
+        for (int i = 0; i != localCount; ++i) {
+            cursor.moveToPosition(i); // TODO: throw exception here if returns
+                                      // false?
+            local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), 0);
+        }
+        cursor.close();
+        Arrays.sort(local);
+
+        // Merge the truth from the API into the local database.
+        final EntrySchema albumSchema = AlbumEntry.SCHEMA;
+        final EntryMetadata key = new EntryMetadata();
+        final AccountManager accountManager = AccountManager.get(getContext());
+        int result = context.api.getAlbums(
+                accountManager, syncResult, user, new GDataParser.EntryHandler() {
+            public void handleEntry(Entry entry) {
+                AlbumEntry album = (AlbumEntry) entry;
+                long albumId = album.id;
+                key.id = albumId;
+                int index = Arrays.binarySearch(local, key);
+                EntryMetadata metadata = index >= 0 ? local[index] : null;
+                if (metadata == null || metadata.dateEdited < album.dateEdited) {
+                    // Insert / update.
+                    Log.i(TAG, "insert / update album " + album.title);
+                    album.syncAccount = user.account;
+                    album.photosDirty = true;
+                    albumSchema.insertOrReplace(db, album);
+                    if (metadata == null) {
+                        context.albumsAdded.add(albumId);
+                    }
+                    ++syncResult.stats.numUpdates;
+                } else {
+                    // Up-to-date.
+                    // Log.i(TAG, "up-to-date album " + album.title);
+                }
+
+                // Mark item as surviving so it is not deleted.
+                if (metadata != null) {
+                    metadata.survived = true;
+                }
+            }
+        });
+
+        // Return if not modified or on error.
+        switch (result) {
+        case PicasaApi.RESULT_ERROR:
+            ++syncResult.stats.numParseExceptions;
+        case PicasaApi.RESULT_NOT_MODIFIED:
+            return;
+        }
+
+        // Update the user entry with the new ETag.
+        UserEntry.SCHEMA.insertOrReplace(db, user);
+
+        // Delete all entries not present in the API response.
+        for (int i = 0; i != localCount; ++i) {
+            EntryMetadata metadata = local[i];
+            if (!metadata.survived) {
+                deleteAlbum(db, metadata.id);
+                ++syncResult.stats.numDeletes;
+                Log.i(TAG, "delete album " + metadata.id);
+            }
+        }
+
+        // Note that albums changed.
+        context.albumsChanged = true;
+    }
+
+    private void syncUserPhotos(SyncContext context, String account, SyncResult syncResult) {
+        // Synchronize albums with out-of-date photos.
+        SQLiteDatabase db = context.db;
+        Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(),
+                Entry.ID_PROJECTION, "sync_account=? AND photos_dirty=1",
+                new String[] { account }, null, null, null);
+        AlbumEntry album = new AlbumEntry();
+        for (int i = 0, count = cursor.getCount(); i != count; ++i) {
+            cursor.moveToPosition(i);
+            if (AlbumEntry.SCHEMA.queryWithId(db, cursor.getLong(0), album)) {
+                syncAlbumPhotos(context, account, album, syncResult);
+            }
+
+            // Abort if interrupted.
+            if (Thread.interrupted()) {
+                ++syncResult.stats.numIoExceptions;
+                Log.e(TAG, "syncUserPhotos interrupted");
+            }
+        }
+        cursor.close();
+    }
+
+    private void syncAlbumPhotos(SyncContext context, final String account,
+            AlbumEntry album, final SyncResult syncResult) {
+        Log.i(TAG, "Syncing Picasa album: " + album.title);
+        // Query existing album entry (id, dateEdited) sorted by ID.
+        final SQLiteDatabase db = context.db;
+        long albumId = album.id;
+        String[] albumIdArgs = { Long.toString(albumId) };
+        Cursor cursor = db.query(PhotoEntry.SCHEMA.getTableName(), ID_EDITED_INDEX_PROJECTION, WHERE_ALBUM_ID, albumIdArgs, null,
+                null, "date_edited");
+        int localCount = cursor.getCount();
+
+        // Build a sorted index with existing entry timestamps and display
+        // indexes.
+        final EntryMetadata local[] = new EntryMetadata[localCount];
+        final EntryMetadata key = new EntryMetadata();
+        for (int i = 0; i != localCount; ++i) {
+            cursor.moveToPosition(i); // TODO: throw exception here if returns
+                                      // false?
+            local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), cursor.getInt(2));
+        }
+        cursor.close();
+        Arrays.sort(local);
+
+        // Merge the truth from the API into the local database.
+        final EntrySchema photoSchema = PhotoEntry.SCHEMA;
+        final int[] displayIndex = { 0 };
+        final AccountManager accountManager = AccountManager.get(getContext());
+        int result = context.api.getAlbumPhotos(
+                accountManager, syncResult, album, new GDataParser.EntryHandler() {
+            public void handleEntry(Entry entry) {
+                PhotoEntry photo = (PhotoEntry) entry;
+                long photoId = photo.id;
+                int newDisplayIndex = displayIndex[0];
+                key.id = photoId;
+                int index = Arrays.binarySearch(local, key);
+                EntryMetadata metadata = index >= 0 ? local[index] : null;
+                if (metadata == null || metadata.dateEdited < photo.dateEdited
+                        || metadata.displayIndex != newDisplayIndex) {
+
+                    // Insert / update.
+                    // Log.i(TAG, "insert / update photo " + photo.title);
+                    photo.syncAccount = account;
+                    photo.displayIndex = newDisplayIndex;
+                    photoSchema.insertOrReplace(db, photo);
+                    ++syncResult.stats.numUpdates;
+                } else {
+                    // Up-to-date.
+                    // Log.i(TAG, "up-to-date photo " + photo.title);
+                }
+
+                // Mark item as surviving so it is not deleted.
+                if (metadata != null) {
+                    metadata.survived = true;
+                }
+
+                // Increment the display index.
+                displayIndex[0] = newDisplayIndex + 1;
+            }
+        });
+
+        // Return if not modified or on error.
+        switch (result) {
+        case PicasaApi.RESULT_ERROR:
+            ++syncResult.stats.numParseExceptions;
+            Log.e(TAG, "syncAlbumPhotos error");
+        case PicasaApi.RESULT_NOT_MODIFIED:
+            // Log.e(TAG, "result not modified");
+            return;
+        }
+
+        // Delete all entries not present in the API response.
+        for (int i = 0; i != localCount; ++i) {
+            EntryMetadata metadata = local[i];
+            if (!metadata.survived) {
+                deletePhoto(db, metadata.id);
+                ++syncResult.stats.numDeletes;
+                // Log.i(TAG, "delete photo " + metadata.id);
+            }
+        }
+
+        // Mark album as no longer dirty and store the new ETag.
+        album.photosDirty = false;
+        AlbumEntry.SCHEMA.insertOrReplace(db, album);
+        // Log.i(TAG, "Clearing dirty bit on album " + albumId);
+
+        // Mark that photos changed.
+        // context.photosChanged = true;
+        getContext().getContentResolver().notifyChange(ALBUMS_URI, null, false);
+        getContext().getContentResolver().notifyChange(PHOTOS_URI, null, false);
+    }
+
+    private void deleteUser(SQLiteDatabase db, String account) {
+        Log.w(TAG, "deleteUser(" + account + ")");
+
+        // Select albums owned by the user.
+        String albumTableName = AlbumEntry.SCHEMA.getTableName();
+        String[] whereArgs = { account };
+        Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(),
+                Entry.ID_PROJECTION, WHERE_ACCOUNT, whereArgs, null, null, null);
+
+        // Delete contained photos for each album.
+        if (cursor.moveToFirst()) {
+            do {
+                deleteAlbumPhotos(db, cursor.getLong(0));
+            } while (cursor.moveToNext());
+        }
+        cursor.close();
+
+        // Delete all albums.
+        db.delete(albumTableName, WHERE_ACCOUNT, whereArgs);
+
+        // Delete the user entry.
+        db.delete(UserEntry.SCHEMA.getTableName(), "account=?", whereArgs);
+    }
+
+    private void deleteAlbum(SQLiteDatabase db, long albumId) {
+        // Delete contained photos.
+        deleteAlbumPhotos(db, albumId);
+
+        // Delete the album.
+        AlbumEntry.SCHEMA.deleteWithId(db, albumId);
+    }
+
+    private void deleteAlbumPhotos(SQLiteDatabase db, long albumId) {
+        Log.v(TAG, "deleteAlbumPhotos(" + albumId + ")");
+        String photoTableName = PhotoEntry.SCHEMA.getTableName();
+        String[] whereArgs = { Long.toString(albumId) };
+        Cursor cursor = db.query(photoTableName,
+                Entry.ID_PROJECTION, WHERE_ALBUM_ID, whereArgs, null, null, null);
+
+        // Delete cache entry for each photo.
+        if (cursor.moveToFirst()) {
+            do {
+                deletePhotoCache(cursor.getLong(0));
+            } while (cursor.moveToNext());
+        }
+        cursor.close();
+
+        // Delete all photos.
+        db.delete(photoTableName, WHERE_ALBUM_ID, whereArgs);
+    }
+
+    private void deletePhoto(SQLiteDatabase db, long photoId) {
+        PhotoEntry.SCHEMA.deleteWithId(db, photoId);
+        deletePhotoCache(photoId);
+    }
+
+    private void deletePhotoCache(long photoId) {
+        // TODO: implement it.
+    }
+
+    private final class SyncContext {
+        // List of all authenticated user accounts.
+        public PicasaApi.AuthAccount[] accounts;
+
+        // A connection to the Picasa API for a specific user account. Initially
+        // null.
+        public PicasaApi api = new PicasaApi();
+
+        // A handle to the Picasa databse.
+        public SQLiteDatabase db;
+
+        // List of album IDs that were added during the sync.
+        public final ArrayList<Long> albumsAdded = new ArrayList<Long>();
+
+        // Set to true if albums were changed.
+        public boolean albumsChanged = false;
+
+        // Set to true if photos were changed.
+        public boolean photosChanged = false;
+
+        public SyncContext() {
+            db = mDatabase.getWritableDatabase();
+        }
+
+        public void reloadAccounts() {
+            accounts = PicasaApi.getAuthenticatedAccounts(getContext());
+        }
+
+        public void finish() {
+            // Send notifications if needed and reset state.
+            ContentResolver cr = getContext().getContentResolver();
+            if (albumsChanged) {
+                cr.notifyChange(ALBUMS_URI, null, false);
+            }
+            if (photosChanged) {
+                cr.notifyChange(PHOTOS_URI, null, false);
+            }
+            albumsChanged = false;
+            photosChanged = false;
+        }
+
+        public boolean login(String user) {
+            if (accounts == null) {
+                reloadAccounts();
+            }
+            final PicasaApi.AuthAccount[] authAccounts = accounts;
+            for (PicasaApi.AuthAccount auth : authAccounts) {
+                if (auth.user.equals(user)) {
+                    api.setAuth(auth);
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Minimal metadata gathered during sync.
+     */
+    private static final class EntryMetadata implements Comparable<EntryMetadata> {
+        public long id;
+        public long dateEdited;
+        public int displayIndex;
+        public boolean survived = false;
+
+        public EntryMetadata() {
+        }
+
+        public EntryMetadata(long id, long dateEdited, int displayIndex) {
+            this.id = id;
+            this.dateEdited = dateEdited;
+            this.displayIndex = displayIndex;
+        }
+
+        public int compareTo(EntryMetadata other) {
+            return Long.signum(id - other.id);
+        }
+
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PicasaReceiver.java b/new3d/src/com/android/gallery3d/picasa/PicasaReceiver.java
new file mode 100644
index 0000000..3e28820
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PicasaReceiver.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public class PicasaReceiver extends BroadcastReceiver {
+
+    private static final String TAG = "PicasaRecevier";
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        Log.v(TAG, "Accounts changed: " + intent);
+    }
+
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PicasaService.java b/new3d/src/com/android/gallery3d/picasa/PicasaService.java
new file mode 100644
index 0000000..bc2adc7
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PicasaService.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AuthenticatorException;
+import android.accounts.OperationCanceledException;
+import android.app.Service;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SyncResult;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Process;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public final class PicasaService extends Service {
+    public static final String ACTION_SYNC = "com.android.gallery3d.picasa.action.SYNC";
+    public static final String ACTION_PERIODIC_SYNC =
+            "com.android.gallery3d.picasa.action.PERIODIC_SYNC";
+    public static final String ACCOUNT_TYPE = "com.google";
+    public static final String SERVICE_NAME = "lh2";
+    public static final String FEATURE_SERVICE_NAME = "service_" + SERVICE_NAME;
+    public static final String KEY_TYPE = "com.android.gallery3d.SYNC_TYPE";
+    public static final String KEY_ID = "com.android.gallery3d.SYNC_ID";
+    public static final int TYPE_USERS = 0;
+    public static final int TYPE_USERS_ALBUMS = 1;
+    public static final int TYPE_ALBUM_PHOTOS = 2;
+
+    private final HandlerThread mSyncThread = new HandlerThread("PicasaSyncThread");
+    private final Handler mSyncHandler;
+    private static final AtomicBoolean sSyncPending = new AtomicBoolean(false);
+
+    public static void requestSync(Context context, int type, long id) {
+        Bundle extras = new Bundle();
+        extras.putInt(KEY_TYPE, type);
+        extras.putLong(KEY_ID, id);
+
+        Account[] accounts = PicasaApi.getAccounts(context);
+        for (Account account : accounts) {
+            ContentResolver.requestSync(account, PicasaContentProvider.AUTHORITY, extras);
+        }
+
+        // context.startService(new Intent(context,
+        // PicasaService.class).putExtras(extras));
+    }
+
+    public PicasaService() {
+        mSyncThread.start();
+        mSyncHandler = new Handler(mSyncThread.getLooper());
+        mSyncHandler.post(new Runnable() {
+            public void run() {
+                Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
+            }
+        });
+    }
+
+    private static PicasaContentProvider getContentProvider(Context context) {
+        ContentResolver cr = context.getContentResolver();
+        ContentProviderClient client =
+                cr.acquireContentProviderClient(PicasaContentProvider.AUTHORITY);
+        return (PicasaContentProvider) client.getLocalContentProvider();
+    }
+
+    @Override
+    public int onStartCommand(final Intent intent, int flags, final int startId) {
+        mSyncHandler.post(new Runnable() {
+            public void run() {
+                performSync(PicasaService.this, null, intent.getExtras(), new SyncResult());
+                stopSelf(startId);
+            }
+        });
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return new PicasaSyncAdapter(getApplicationContext()).getSyncAdapterBinder();
+    }
+
+    @Override
+    public void onDestroy() {
+        mSyncThread.quit();
+    }
+
+    public static boolean performSync(
+            Context context, Account account, Bundle extras, SyncResult syncResult) {
+        // Skip if another sync is pending.
+        if (!sSyncPending.compareAndSet(false, true)) {
+            return false;
+        }
+
+        // Perform the sync.
+        performSyncImpl(context, account, extras, syncResult);
+
+        // Mark sync as complete and notify all waiters.
+        sSyncPending.set(false);
+        synchronized (sSyncPending) {
+            sSyncPending.notifyAll();
+        }
+        return true;
+    }
+
+    public static void waitForPerformSync() {
+        synchronized (sSyncPending) {
+            while (sSyncPending.get()) {
+                try {
+                    // Wait for the sync to complete.
+                    sSyncPending.wait();
+                } catch (InterruptedException e) {
+                    // Stop waiting if interrupted.
+                    break;
+                }
+            }
+        }
+    }
+
+    private static void performSyncImpl(
+            Context context, Account account, Bundle extras, SyncResult syncResult) {
+        // Initialize newly added accounts to sync by default.
+        String authority = PicasaContentProvider.AUTHORITY;
+        if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_INITIALIZE, false)) {
+            if (account != null && ContentResolver.getIsSyncable(account, authority) < 0) {
+                try {
+                    ContentResolver.setIsSyncable(
+                            account, authority, getIsSyncable(context, account) ? 1 : 0);
+                } catch (OperationCanceledException e) {
+                } catch (IOException e) {
+                }
+            }
+            return;
+        }
+
+        // Do nothing if sync is disabled for this account. TODO: is this
+        // blocked in PicasaContentProvider too?
+        if (account != null && ContentResolver.getIsSyncable(account, authority) < 0) {
+            ++syncResult.stats.numSkippedEntries;
+            return;
+        }
+
+        // Get the type of sync operation and the entity ID, if applicable.
+        // Default to synchronize all.
+        int type = extras.getInt(PicasaService.KEY_TYPE, PicasaService.TYPE_USERS_ALBUMS);
+        long id = extras.getLong(PicasaService.KEY_ID, -1);
+
+        // Get the content provider instance and reload the list of user
+        // accounts.
+        PicasaContentProvider provider = getContentProvider(context);
+        provider.reloadAccounts();
+
+        // Restrict sync to either a specific account or all accounts.
+        provider.setActiveSyncAccount(account);
+
+        // Perform the desired sync operation.
+        switch (type) {
+        case PicasaService.TYPE_USERS:
+            provider.syncUsers(syncResult);
+            break;
+        case PicasaService.TYPE_USERS_ALBUMS:
+            provider.syncUsersAndAlbums(true, syncResult);
+            break;
+        case PicasaService.TYPE_ALBUM_PHOTOS:
+            provider.syncAlbumPhotos(id, true, syncResult);
+            break;
+        default:
+            throw new IllegalArgumentException();
+        }
+    }
+
+    private static boolean getIsSyncable(Context context, Account account)
+            throws IOException, OperationCanceledException {
+        try {
+            Account[] picasaAccounts = AccountManager.get(context).getAccountsByTypeAndFeatures(
+                    ACCOUNT_TYPE, new String[] { FEATURE_SERVICE_NAME },
+                    null /* callback */, null /* handler */).getResult();
+            for (Account picasaAccount : picasaAccounts) {
+                if (account.equals(picasaAccount)) {
+                    return true;
+                }
+            }
+            return false;
+        } catch (AuthenticatorException e) {
+            throw new IOException(e.getMessage());
+        }
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/PicasaSyncAdapter.java b/new3d/src/com/android/gallery3d/picasa/PicasaSyncAdapter.java
new file mode 100644
index 0000000..5d5ae60
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/PicasaSyncAdapter.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.BroadcastReceiver;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SyncResult;
+import android.os.Bundle;
+import android.util.Log;
+
+public class PicasaSyncAdapter extends AbstractThreadedSyncAdapter {
+    private final Context mContext;
+    public final static String TAG = "PicasaSyncAdapter";
+
+    public PicasaSyncAdapter(Context applicationContext) {
+        super(applicationContext, false);
+        mContext = applicationContext;
+    }
+
+    @Override
+    public void onPerformSync(Account account, Bundle extras, String authority,
+            ContentProviderClient providerClient, SyncResult syncResult) {
+        if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_INITIALIZE, false)) {
+            try {
+                Account[] picasaAccounts = AccountManager.get(getContext())
+                        .getAccountsByTypeAndFeatures(
+                        PicasaService.ACCOUNT_TYPE,
+                        new String[] { PicasaService.FEATURE_SERVICE_NAME },
+                        null /* callback */, null /* handler */).getResult();
+                boolean isPicasaAccount = false;
+                for (Account picasaAccount : picasaAccounts) {
+                    if (account.equals(picasaAccount)) {
+                        isPicasaAccount = true;
+                        break;
+                    }
+                }
+                if (isPicasaAccount) {
+                    ContentResolver.setIsSyncable(account, authority, 1);
+                    ContentResolver.setSyncAutomatically(account, authority, true);
+                }
+            } catch (Exception e) {
+                Log.e(TAG, "cannot do sync", e);
+            }
+            return;
+        }
+        try {
+            PicasaService.performSync(mContext, account, extras, syncResult);
+        } catch (Exception e) {
+            // Report an error
+            ++syncResult.stats.numIoExceptions;
+        }
+    }
+
+    public static final class AccountChangeReceiver extends BroadcastReceiver {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // TODO: Need to get account list change broadcast.
+        }
+
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/TableContentProvider.java b/new3d/src/com/android/gallery3d/picasa/TableContentProvider.java
new file mode 100644
index 0000000..8f8d9fd
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/TableContentProvider.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import java.util.ArrayList;
+
+public class TableContentProvider extends ContentProvider {
+    private static final String NULL_COLUMN_HACK = "_id";
+    protected SQLiteOpenHelper mDatabase = null;
+    private final UriMatcher mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+    private final ArrayList<Mapping> mMappings = new ArrayList<Mapping>();
+
+    public void setDatabase(SQLiteOpenHelper database) {
+        mDatabase = database;
+    }
+
+    public void addMapping(String authority, String path, String mimeSubtype, EntrySchema table) {
+        // Add the table URI mapping.
+        ArrayList<Mapping> mappings = mMappings;
+        UriMatcher matcher = mUriMatcher;
+        matcher.addURI(authority, path, mappings.size());
+        mappings.add(new Mapping(table, mimeSubtype, false));
+
+        // Add the row URI mapping.
+        matcher.addURI(authority, path + "/#", mappings.size());
+        mappings.add(new Mapping(table, mimeSubtype, true));
+    }
+
+    @Override
+    public boolean onCreate() {
+        // The database may not be loaded yet since attachInfo() has not been
+        // called, so we cannot
+        // check that the database opened successfully. Returns true
+        // optimistically.
+        return true;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        if (match == UriMatcher.NO_MATCH) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Combine the standard type with the user-provided subtype.
+        Mapping mapping = mMappings.get(match);
+        String prefix = mapping.hasId
+                ? ContentResolver.CURSOR_ITEM_BASE_TYPE
+                : ContentResolver.CURSOR_DIR_BASE_TYPE;
+        return prefix + "/" + mapping.mimeSubtype;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection,
+            String selection, String[] selectionArgs, String sortOrder) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        if (match == UriMatcher.NO_MATCH) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Add the ID predicate if needed.
+        Mapping mapping = mMappings.get(match);
+        if (mapping.hasId) {
+            selection = whereWithId(uri, selection);
+        }
+
+        // System.out.println("QUERY " + uri + " WHERE (" + selection + ")");
+
+        // Run the query.
+        String tableName = mapping.table.getTableName();
+        Cursor cursor = mDatabase.getReadableDatabase().query(
+                tableName, projection, selection, selectionArgs, null, null, sortOrder);
+        cursor.setNotificationUri(getContext().getContentResolver(), uri);
+        return cursor;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        Mapping mapping = match != UriMatcher.NO_MATCH ? mMappings.get(match) : null;
+        if (mapping == null || mapping.hasId) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Insert into the database, notify observers, and return the qualified
+        // URI.
+        String tableName = mapping.table.getTableName();
+        long rowId = mDatabase.getWritableDatabase().insert(tableName, NULL_COLUMN_HACK, values);
+        if (rowId > 0) {
+            notifyChange(uri);
+            return Uri.withAppendedPath(uri, Long.toString(rowId));
+        } else {
+            throw new SQLException("Failed to insert row at: " + uri);
+        }
+    }
+
+    @Override
+    public int bulkInsert(Uri uri, ContentValues[] values) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        Mapping mapping = match != UriMatcher.NO_MATCH ? mMappings.get(match) : null;
+        if (mapping == null || mapping.hasId) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Insert all rows into the database and notify observers.
+        String tableName = mapping.table.getTableName();
+        SQLiteDatabase database = mDatabase.getWritableDatabase();
+        int numInserted = 0;
+        try {
+            int length = values.length;
+            database.beginTransaction();
+            for (int i = 0; i != length; ++i) {
+                database.insert(tableName, NULL_COLUMN_HACK, values[i]);
+            }
+            database.setTransactionSuccessful();
+            numInserted = length;
+        } finally {
+            database.endTransaction();
+        }
+        notifyChange(uri);
+        return numInserted;
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        if (match == UriMatcher.NO_MATCH) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Add the ID predicate if needed.
+        Mapping mapping = mMappings.get(match);
+        if (mapping.hasId) {
+            selection = whereWithId(uri, selection);
+        }
+
+        // Update the item(s) and broadcast a change notification.
+        SQLiteDatabase db = mDatabase.getWritableDatabase();
+        String tableName = mapping.table.getTableName();
+        int count = db.update(tableName, values, selection, selectionArgs);
+        notifyChange(uri);
+        return count;
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        // Resolve the URI.
+        int match = mUriMatcher.match(uri);
+        if (match == UriMatcher.NO_MATCH) {
+            throw new IllegalArgumentException("Invalid URI: " + uri);
+        }
+
+        // Add the ID predicate if needed.
+        Mapping mapping = mMappings.get(match);
+        if (mapping.hasId) {
+            selection = whereWithId(uri, selection);
+        }
+
+        // Delete the item(s) and broadcast a change notification.
+        SQLiteDatabase db = mDatabase.getWritableDatabase();
+        String tableName = mapping.table.getTableName();
+        int count = db.delete(tableName, selection, selectionArgs);
+        notifyChange(uri);
+        return count;
+    }
+
+    private final String whereWithId(Uri uri, String selection) {
+        String id = uri.getPathSegments().get(1);
+        StringBuilder where = new StringBuilder("_id=");
+        where.append(id);
+        if (!TextUtils.isEmpty(selection)) {
+            where.append(" AND (");
+            where.append(selection);
+            where.append(')');
+        }
+        return where.toString();
+    }
+
+    private final void notifyChange(Uri uri) {
+        getContext().getContentResolver().notifyChange(uri, null);
+    }
+
+    private static final class Mapping {
+        public EntrySchema table;
+        public String mimeSubtype;
+        public boolean hasId;
+
+        public Mapping(EntrySchema table, String mimeSubtype, boolean hasId) {
+            this.table = table;
+            this.mimeSubtype = mimeSubtype;
+            this.hasId = hasId;
+        }
+    }
+}
diff --git a/new3d/src/com/android/gallery3d/picasa/UserEntry.java b/new3d/src/com/android/gallery3d/picasa/UserEntry.java
new file mode 100644
index 0000000..a0c86d1
--- /dev/null
+++ b/new3d/src/com/android/gallery3d/picasa/UserEntry.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.picasa;
+
+import org.xml.sax.Attributes;
+
+@Entry.Table("users")
+public final class UserEntry extends Entry {
+    public static final EntrySchema SCHEMA = new EntrySchema(UserEntry.class);
+
+    @Column("account")
+    public String account;
+
+    @Column("albums_etag")
+    public String albumsEtag;
+
+    @Override
+    public void setPropertyFromXml(String uri, String localName, Attributes attrs, String content) {
+    }
+}