Change where saved pages are stored

 Bug: 5416822
 Move saved pages out of external storage (b/5605575)
 Save them as files instead of in the database, as the database
 has a row size limit

Change-Id: I03b5af2459724d8cab67a9acfcc2827c7129e80f
diff --git a/src/com/android/browser/BrowserSnapshotPage.java b/src/com/android/browser/BrowserSnapshotPage.java
index be4f9af..b0d8205 100644
--- a/src/com/android/browser/BrowserSnapshotPage.java
+++ b/src/com/android/browser/BrowserSnapshotPage.java
@@ -61,7 +61,7 @@
     private static final String[] PROJECTION = new String[] {
         Snapshots._ID,
         Snapshots.TITLE,
-        "length(" + Snapshots.VIEWSTATE + ")",
+        Snapshots.VIEWSTATE_SIZE,
         Snapshots.THUMBNAIL,
         Snapshots.FAVICON,
         Snapshots.URL,
@@ -69,7 +69,7 @@
     };
     private static final int SNAPSHOT_ID = 0;
     private static final int SNAPSHOT_TITLE = 1;
-    private static final int SNAPSHOT_VIEWSTATE_LENGTH = 2;
+    private static final int SNAPSHOT_VIEWSTATE_SIZE = 2;
     private static final int SNAPSHOT_THUMBNAIL = 3;
     private static final int SNAPSHOT_FAVICON = 4;
     private static final int SNAPSHOT_URL = 5;
@@ -281,7 +281,7 @@
             title.setText(cursor.getString(SNAPSHOT_TITLE));
             TextView size = (TextView) view.findViewById(R.id.size);
             if (size != null) {
-                int stateLen = cursor.getInt(SNAPSHOT_VIEWSTATE_LENGTH);
+                int stateLen = cursor.getInt(SNAPSHOT_VIEWSTATE_SIZE);
                 size.setText(String.format("%.2fMB", stateLen / 1024f / 1024f));
             }
             long timestamp = cursor.getLong(SNAPSHOT_DATE_CREATED);
diff --git a/src/com/android/browser/SnapshotByteArrayOutputStream.java b/src/com/android/browser/SnapshotByteArrayOutputStream.java
deleted file mode 100644
index 127eee8..0000000
--- a/src/com/android/browser/SnapshotByteArrayOutputStream.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright (C) 2011 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.browser;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-
-public class SnapshotByteArrayOutputStream extends OutputStream {
-
-    // Maximum size, this needs to be small enough such that an entire row
-    // can fit in CursorWindow's 2MB limit
-    private static final int MAX_SIZE = 1700000;
-    private ByteArrayOutputStream mStream;
-
-    public SnapshotByteArrayOutputStream() {
-        mStream = new ByteArrayOutputStream(MAX_SIZE);
-    }
-
-    @Override
-    public synchronized void write(int oneByte) throws IOException {
-        checkError(1);
-        mStream.write(oneByte);
-    }
-
-    @Override
-    public void write(byte[] buffer, int offset, int count) throws IOException {
-        checkError(count);
-        mStream.write(buffer, offset, count);
-    }
-
-    private void checkError(int expandBy) throws IOException {
-        if ((size() + expandBy) > MAX_SIZE) {
-            throw new IOException("Exceeded max size!");
-        }
-    }
-
-    public int size() {
-        return mStream.size();
-    }
-
-    public byte[] toByteArray() {
-        return mStream.toByteArray();
-    }
-
-}
diff --git a/src/com/android/browser/SnapshotTab.java b/src/com/android/browser/SnapshotTab.java
index f58f88b..e14f095 100644
--- a/src/com/android/browser/SnapshotTab.java
+++ b/src/com/android/browser/SnapshotTab.java
@@ -18,11 +18,13 @@
 import android.content.ContentResolver;
 import android.content.ContentUris;
 import android.content.ContentValues;
+import android.content.Context;
 import android.database.Cursor;
 import android.graphics.BitmapFactory;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.text.TextUtils;
 import android.util.Log;
 import android.webkit.WebView;
 import android.webkit.WebViewClassic;
@@ -30,6 +32,8 @@
 import com.android.browser.provider.SnapshotProvider.Snapshots;
 
 import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
 import java.util.Map;
 import java.util.zip.GZIPInputStream;
 
@@ -75,7 +79,7 @@
 
     void loadData() {
         if (mLoadTask == null) {
-            mLoadTask = new LoadData(this, mContext.getContentResolver());
+            mLoadTask = new LoadData(this, mContext);
             mLoadTask.execute();
         }
     }
@@ -152,20 +156,31 @@
 
         static final String[] PROJECTION = new String[] {
             Snapshots._ID, // 0
-            Snapshots.TITLE, // 1
-            Snapshots.URL, // 2
+            Snapshots.URL, // 1
+            Snapshots.TITLE, // 2
             Snapshots.FAVICON, // 3
             Snapshots.VIEWSTATE, // 4
             Snapshots.BACKGROUND, // 5
             Snapshots.DATE_CREATED, // 6
+            Snapshots.VIEWSTATE_PATH, // 7
         };
+        static final int SNAPSHOT_ID = 0;
+        static final int SNAPSHOT_URL = 1;
+        static final int SNAPSHOT_TITLE = 2;
+        static final int SNAPSHOT_FAVICON = 3;
+        static final int SNAPSHOT_VIEWSTATE = 4;
+        static final int SNAPSHOT_BACKGROUND = 5;
+        static final int SNAPSHOT_DATE_CREATED = 6;
+        static final int SNAPSHOT_VIEWSTATE_PATH = 7;
 
         private SnapshotTab mTab;
         private ContentResolver mContentResolver;
+        private Context mContext;
 
-        public LoadData(SnapshotTab t, ContentResolver cr) {
+        public LoadData(SnapshotTab t, Context context) {
             mTab = t;
-            mContentResolver = cr;
+            mContentResolver = context.getContentResolver();
+            mContext = context;
         }
 
         @Override
@@ -175,26 +190,35 @@
             return mContentResolver.query(uri, PROJECTION, null, null, null);
         }
 
+        private InputStream getInputStream(Cursor c) throws FileNotFoundException {
+            String path = c.getString(SNAPSHOT_VIEWSTATE_PATH);
+            if (!TextUtils.isEmpty(path)) {
+                return mContext.openFileInput(path);
+            }
+            byte[] data = c.getBlob(SNAPSHOT_VIEWSTATE);
+            ByteArrayInputStream bis = new ByteArrayInputStream(data);
+            return bis;
+        }
+
         @Override
         protected void onPostExecute(Cursor result) {
             try {
                 if (result.moveToFirst()) {
-                    mTab.mCurrentState.mTitle = result.getString(1);
-                    mTab.mCurrentState.mUrl = result.getString(2);
-                    byte[] favicon = result.getBlob(3);
+                    mTab.mCurrentState.mTitle = result.getString(SNAPSHOT_TITLE);
+                    mTab.mCurrentState.mUrl = result.getString(SNAPSHOT_URL);
+                    byte[] favicon = result.getBlob(SNAPSHOT_FAVICON);
                     if (favicon != null) {
                         mTab.mCurrentState.mFavicon = BitmapFactory
                                 .decodeByteArray(favicon, 0, favicon.length);
                     }
                     WebViewClassic web = mTab.getWebViewClassic();
                     if (web != null) {
-                        byte[] data = result.getBlob(4);
-                        ByteArrayInputStream bis = new ByteArrayInputStream(data);
-                        GZIPInputStream stream = new GZIPInputStream(bis);
+                        InputStream ins = getInputStream(result);
+                        GZIPInputStream stream = new GZIPInputStream(ins);
                         web.loadViewState(stream);
                     }
-                    mTab.mBackgroundColor = result.getInt(5);
-                    mTab.mDateCreated = result.getLong(6);
+                    mTab.mBackgroundColor = result.getInt(SNAPSHOT_BACKGROUND);
+                    mTab.mDateCreated = result.getLong(SNAPSHOT_DATE_CREATED);
                     mTab.mWebViewController.onPageFinished(mTab);
                 }
             } catch (Exception e) {
diff --git a/src/com/android/browser/Tab.java b/src/com/android/browser/Tab.java
index 9b5a675..04bee08 100644
--- a/src/com/android/browser/Tab.java
+++ b/src/com/android/browser/Tab.java
@@ -64,8 +64,8 @@
 import android.webkit.WebResourceResponse;
 import android.webkit.WebStorage;
 import android.webkit.WebView;
-import android.webkit.WebViewClassic;
 import android.webkit.WebView.PictureListener;
+import android.webkit.WebViewClassic;
 import android.webkit.WebViewClient;
 import android.widget.CheckBox;
 import android.widget.Toast;
@@ -76,12 +76,15 @@
 import com.android.common.speech.LoggingEvents;
 
 import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.OutputStream;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.Map;
+import java.util.UUID;
 import java.util.Vector;
 import java.util.regex.Pattern;
 import java.util.zip.GZIPOutputStream;
@@ -2059,9 +2062,10 @@
 
     public ContentValues createSnapshotValues() {
         if (mMainView == null) return null;
-        SnapshotByteArrayOutputStream bos = new SnapshotByteArrayOutputStream();
+        String path = UUID.randomUUID().toString();
         try {
-            GZIPOutputStream stream = new GZIPOutputStream(bos);
+            OutputStream outs = mContext.openFileOutput(path, Context.MODE_PRIVATE);
+            GZIPOutputStream stream = new GZIPOutputStream(outs);
             if (!getWebViewClassic().saveViewState(stream)) {
                 return null;
             }
@@ -2071,11 +2075,13 @@
             Log.w(LOGTAG, "Failed to save view state", e);
             return null;
         }
-        byte[] data = bos.toByteArray();
+        File savedFile = mContext.getFileStreamPath(path);
+        long size = savedFile.length();
         ContentValues values = new ContentValues();
         values.put(Snapshots.TITLE, mCurrentState.mTitle);
         values.put(Snapshots.URL, mCurrentState.mUrl);
-        values.put(Snapshots.VIEWSTATE, data);
+        values.put(Snapshots.VIEWSTATE_PATH, path);
+        values.put(Snapshots.VIEWSTATE_SIZE, size);
         values.put(Snapshots.BACKGROUND, getWebViewClassic().getPageBackgroundColor());
         values.put(Snapshots.DATE_CREATED, System.currentTimeMillis());
         values.put(Snapshots.FAVICON, compressBitmap(getFavicon()));
diff --git a/src/com/android/browser/provider/SnapshotProvider.java b/src/com/android/browser/provider/SnapshotProvider.java
index 437a867..291e93b 100644
--- a/src/com/android/browser/provider/SnapshotProvider.java
+++ b/src/com/android/browser/provider/SnapshotProvider.java
@@ -15,13 +15,10 @@
  */
 package com.android.browser.provider;
 
-import android.content.BroadcastReceiver;
 import android.content.ContentProvider;
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
 import android.content.UriMatcher;
 import android.database.Cursor;
 import android.database.DatabaseUtils;
@@ -29,15 +26,11 @@
 import android.database.sqlite.SQLiteOpenHelper;
 import android.database.sqlite.SQLiteQueryBuilder;
 import android.net.Uri;
-import android.os.Environment;
+import android.os.FileUtils;
 import android.provider.BrowserContract;
 
 import java.io.File;
 
-/**
- * This provider is expected to be potentially flaky. It uses a database
- * stored on external storage, which could be yanked unexpectedly.
- */
 public class SnapshotProvider extends ContentProvider {
 
     public static interface Snapshots {
@@ -45,6 +38,7 @@
         public static final Uri CONTENT_URI = Uri.withAppendedPath(
                 SnapshotProvider.AUTHORITY_URI, "snapshots");
         public static final String _ID = "_id";
+        @Deprecated
         public static final String VIEWSTATE = "view_state";
         public static final String BACKGROUND = "background";
         public static final String TITLE = "title";
@@ -52,6 +46,8 @@
         public static final String FAVICON = "favicon";
         public static final String THUMBNAIL = "thumbnail";
         public static final String DATE_CREATED = "date_created";
+        public static final String VIEWSTATE_PATH = "viewstate_path";
+        public static final String VIEWSTATE_SIZE = "viewstate_size";
     }
 
     public static final String AUTHORITY = "com.android.browser.snapshots";
@@ -61,6 +57,8 @@
     static final int SNAPSHOTS = 10;
     static final int SNAPSHOTS_ID = 11;
     static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+    // Workaround that we can't remove the "NOT NULL" constraint on VIEWSTATE
+    static final byte[] NULL_BLOB_HACK = new byte[0];
 
     SnapshotDatabaseHelper mOpenHelper;
 
@@ -72,15 +70,10 @@
     final static class SnapshotDatabaseHelper extends SQLiteOpenHelper {
 
         static final String DATABASE_NAME = "snapshots.db";
-        static final int DATABASE_VERSION = 2;
+        static final int DATABASE_VERSION = 3;
 
         public SnapshotDatabaseHelper(Context context) {
-            super(context, getFullDatabaseName(context), null, DATABASE_VERSION);
-        }
-
-        static String getFullDatabaseName(Context context) {
-            File dir = context.getExternalFilesDir(null);
-            return new File(dir, DATABASE_NAME).getAbsolutePath();
+            super(context, DATABASE_NAME, null, DATABASE_VERSION);
         }
 
         @Override
@@ -93,7 +86,9 @@
                     Snapshots.FAVICON + " BLOB," +
                     Snapshots.THUMBNAIL + " BLOB," +
                     Snapshots.BACKGROUND + " INTEGER," +
-                    Snapshots.VIEWSTATE + " BLOB NOT NULL" +
+                    Snapshots.VIEWSTATE + " BLOB NOT NULL," +
+                    Snapshots.VIEWSTATE_PATH + " TEXT," +
+                    Snapshots.VIEWSTATE_SIZE + "INTEGER" +
                     ");");
         }
 
@@ -103,64 +98,52 @@
                 db.execSQL("DROP TABLE " + TABLE_SNAPSHOTS);
                 onCreate(db);
             }
+            if (oldVersion < 3) {
+                db.execSQL("ALTER TABLE " + TABLE_SNAPSHOTS + " ADD COLUMN "
+                        + Snapshots.VIEWSTATE_PATH + " TEXT");
+                db.execSQL("ALTER TABLE " + TABLE_SNAPSHOTS + " ADD COLUMN "
+                        + Snapshots.VIEWSTATE_SIZE + " INTEGER");
+                db.execSQL("UPDATE " + TABLE_SNAPSHOTS + " SET "
+                        + Snapshots.VIEWSTATE_SIZE + " = length("
+                        + Snapshots.VIEWSTATE + ")");
+            }
         }
 
     }
 
+    static File getOldDatabasePath(Context context) {
+        File dir = context.getExternalFilesDir(null);
+        return new File(dir, SnapshotDatabaseHelper.DATABASE_NAME);
+    }
+
+    private void migrateToDataFolder() {
+        File dbPath = getContext().getDatabasePath(SnapshotDatabaseHelper.DATABASE_NAME);
+        if (dbPath.exists()) return;
+        File oldPath = getOldDatabasePath(getContext());
+        if (oldPath.exists()) {
+            // Try to move
+            if (!oldPath.renameTo(dbPath)) {
+                // Failed, do a copy
+                FileUtils.copyFile(oldPath, dbPath);
+            }
+            // Cleanup
+            oldPath.delete();
+        }
+    }
+
     @Override
     public boolean onCreate() {
-        IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_EJECT);
-        filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
-        getContext().registerReceiver(mExternalStorageReceiver, filter);
+        migrateToDataFolder();
+        mOpenHelper = new SnapshotDatabaseHelper(getContext());
         return true;
     }
 
-    final BroadcastReceiver mExternalStorageReceiver = new BroadcastReceiver() {
-
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            if (mOpenHelper != null) {
-                try {
-                    mOpenHelper.close();
-                } catch (Throwable t) {
-                    // We failed to close the open helper, which most likely means
-                    // another thread is busy attempting to open the database
-                    // or use the database. Let that thread try to gracefully
-                    // deal with the error
-                }
-            }
-        }
-    };
-
     SQLiteDatabase getWritableDatabase() {
-        String state = Environment.getExternalStorageState();
-        if (Environment.MEDIA_MOUNTED.equals(state)) {
-            try {
-                if (mOpenHelper == null) {
-                    mOpenHelper = new SnapshotDatabaseHelper(getContext());
-                }
-                return mOpenHelper.getWritableDatabase();
-            } catch (Throwable t) {
-                return null;
-            }
-        }
-        return null;
+        return mOpenHelper.getWritableDatabase();
     }
 
     SQLiteDatabase getReadableDatabase() {
-        String state = Environment.getExternalStorageState();
-        if (Environment.MEDIA_MOUNTED.equals(state)
-                || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
-            try {
-                if (mOpenHelper == null) {
-                    mOpenHelper = new SnapshotDatabaseHelper(getContext());
-                }
-                return mOpenHelper.getReadableDatabase();
-            } catch (Throwable t) {
-                return null;
-            }
-        }
-        return null;
+        return mOpenHelper.getReadableDatabase();
     }
 
     @Override
@@ -186,15 +169,11 @@
         default:
             throw new UnsupportedOperationException("Unknown URL " + uri.toString());
         }
-        try {
-            Cursor cursor = qb.query(db, projection, selection, selectionArgs,
-                    null, null, sortOrder, limit);
-            cursor.setNotificationUri(getContext().getContentResolver(),
-                    AUTHORITY_URI);
-            return cursor;
-        } catch (Throwable t) {
-            return null;
-        }
+        Cursor cursor = qb.query(db, projection, selection, selectionArgs,
+                null, null, sortOrder, limit);
+        cursor.setNotificationUri(getContext().getContentResolver(),
+                AUTHORITY_URI);
+        return cursor;
     }
 
     @Override
@@ -212,11 +191,10 @@
         long id = -1;
         switch (match) {
         case SNAPSHOTS:
-            try {
-                id = db.insert(TABLE_SNAPSHOTS, Snapshots.TITLE, values);
-            } catch (Throwable t) {
-                id = -1;
+            if (!values.containsKey(Snapshots.VIEWSTATE)) {
+                values.put(Snapshots.VIEWSTATE, NULL_BLOB_HACK);
             }
+            id = db.insert(TABLE_SNAPSHOTS, Snapshots.TITLE, values);
             break;
         default:
             throw new UnsupportedOperationException("Unknown insert URI " + uri);
@@ -229,6 +207,25 @@
         return inserted;
     }
 
+    static final String[] DELETE_PROJECTION = new String[] {
+        Snapshots.VIEWSTATE_PATH,
+    };
+    private void deleteDataFiles(SQLiteDatabase db, String selection,
+            String[] selectionArgs) {
+        Cursor c = db.query(TABLE_SNAPSHOTS, DELETE_PROJECTION, selection,
+                selectionArgs, null, null, null);
+        final Context context = getContext();
+        while (c.moveToNext()) {
+            File f = context.getFileStreamPath(c.getString(0));
+            if (f.exists()) {
+                if (!f.delete()) {
+                    f.deleteOnExit();
+                }
+            }
+        }
+        c.close();
+    }
+
     @Override
     public int delete(Uri uri, String selection, String[] selectionArgs) {
         SQLiteDatabase db = getWritableDatabase();
@@ -245,10 +242,8 @@
             // fall through
         }
         case SNAPSHOTS:
-            try {
-                deleted = db.delete(TABLE_SNAPSHOTS, selection, selectionArgs);
-            } catch (Throwable t) {
-            }
+            deleteDataFiles(db, selection, selectionArgs);
+            deleted = db.delete(TABLE_SNAPSHOTS, selection, selectionArgs);
             break;
         default:
             throw new UnsupportedOperationException("Unknown delete URI " + uri);