[GH] Support opening gzipped databases.

This patch adds a method for creating a database from a gzipped file.
This is only implemented for external files, since assets are
transparently gzipped and extracted by the build tools.

Test: Added a unit test to SQLiteCopyOpenHelperTest.
Fixes: b/146911060

This is an imported pull request from https://github.com/androidx/androidx/pull/49.

Resolves #49
Github-Pr-Head-Sha: 568196d7b1800e9055de0dae7f509008cbc64c71
GitOrigin-RevId: 5de26bc6b6107d6aa2079f53d303e1216879ffa9
Change-Id: Ibd6b85f03748062a1ced22daf250944d5f07ef5b
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/PrepackageTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/PrepackageTest.java
index 03bce56..5f4e930 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/PrepackageTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/PrepackageTest.java
@@ -42,11 +42,17 @@
 import org.junit.runner.RunWith;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.concurrent.Callable;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
 
 @MediumTest
 @RunWith(AndroidJUnit4.class)
@@ -68,6 +74,30 @@
     }
 
     @Test
+    public void createFromZippedAsset() {
+        Context context = ApplicationProvider.getApplicationContext();
+        context.deleteDatabase("products.db");
+
+        final Callable inputStreamCallable = () -> {
+            final ZipInputStream zipInputStream =
+                    new ZipInputStream(
+                            context.getAssets().open("databases/products_v1.db.zip"));
+            zipInputStream.getNextEntry();
+            return zipInputStream;
+        };
+
+        ProductsDatabase database = Room.databaseBuilder(
+                context, ProductsDatabase.class, "products.db")
+                .createFromInputStream(inputStreamCallable)
+                .build();
+
+        ProductDao dao = database.getProductDao();
+        assertThat(dao.countProducts(), is(2));
+
+        database.close();
+    }
+
+    @Test
     public void createFromAsset_badSchema() {
         Context context = ApplicationProvider.getApplicationContext();
         context.deleteDatabase("products_badSchema.db");
@@ -418,6 +448,31 @@
     }
 
     @Test
+    public void createFromInputStream() throws IOException {
+        Context context = ApplicationProvider.getApplicationContext();
+        context.deleteDatabase("products_external.db");
+        File dataDbFile = new File(ContextCompat.getDataDir(context), "products_external.db.gz");
+        context.deleteDatabase(dataDbFile.getAbsolutePath());
+
+        InputStream toCopyInput = context.getAssets().open("databases/products_v1.db");
+
+        // gzip the file while copying it - note that gzipping files in assets doesn't work because
+        // aapt drops the gz extension and makes them available without requiring a GZip stream.
+        final OutputStream output = new GZIPOutputStream(new FileOutputStream(dataDbFile));
+        copyStream(toCopyInput, output);
+
+        ProductsDatabase database = Room.databaseBuilder(
+                context, ProductsDatabase.class, "products_external.db")
+                .createFromInputStream(() -> new GZIPInputStream(new FileInputStream(dataDbFile)))
+                .build();
+
+        ProductDao dao = database.getProductDao();
+        assertThat(dao.countProducts(), is(2));
+
+        database.close();
+    }
+
+    @Test
     public void openDataDirDatabase() throws IOException {
         Context context = ApplicationProvider.getApplicationContext();
 
@@ -525,6 +580,10 @@
 
     private static void copyAsset(InputStream input, File outputFile) throws IOException {
         OutputStream output = new FileOutputStream(outputFile);
+        copyStream(input, output);
+    }
+
+    private static void copyStream(InputStream input, OutputStream output) throws IOException {
         try {
             int length;
             byte[] buffer = new byte[1024 * 4];
diff --git a/room/integration-tests/testapp/src/main/assets/databases/products_v1.db.zip b/room/integration-tests/testapp/src/main/assets/databases/products_v1.db.zip
new file mode 100644
index 0000000..761460c
--- /dev/null
+++ b/room/integration-tests/testapp/src/main/assets/databases/products_v1.db.zip
Binary files differ
diff --git a/room/runtime/api/current.txt b/room/runtime/api/current.txt
index 1d210e5..7322c59 100644
--- a/room/runtime/api/current.txt
+++ b/room/runtime/api/current.txt
@@ -73,6 +73,7 @@
     method public T build();
     method public androidx.room.RoomDatabase.Builder createFromAsset(String);
     method public androidx.room.RoomDatabase.Builder createFromFile(java.io.File);
+    method public androidx.room.RoomDatabase.Builder createFromInputStream(java.util.concurrent.Callable);
     method public androidx.room.RoomDatabase.Builder enableMultiInstanceInvalidation();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigration();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigrationFrom(int...);
diff --git a/room/runtime/api/public_plus_experimental_current.txt b/room/runtime/api/public_plus_experimental_current.txt
index 1d210e5..7322c59 100644
--- a/room/runtime/api/public_plus_experimental_current.txt
+++ b/room/runtime/api/public_plus_experimental_current.txt
@@ -73,6 +73,7 @@
     method public T build();
     method public androidx.room.RoomDatabase.Builder createFromAsset(String);
     method public androidx.room.RoomDatabase.Builder createFromFile(java.io.File);
+    method public androidx.room.RoomDatabase.Builder createFromInputStream(java.util.concurrent.Callable);
     method public androidx.room.RoomDatabase.Builder enableMultiInstanceInvalidation();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigration();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigrationFrom(int...);
diff --git a/room/runtime/api/restricted_current.txt b/room/runtime/api/restricted_current.txt
index c4bbcbe..5fef922 100644
--- a/room/runtime/api/restricted_current.txt
+++ b/room/runtime/api/restricted_current.txt
@@ -112,6 +112,7 @@
     method public T build();
     method public androidx.room.RoomDatabase.Builder createFromAsset(String);
     method public androidx.room.RoomDatabase.Builder createFromFile(java.io.File);
+    method public androidx.room.RoomDatabase.Builder createFromInputStream(java.util.concurrent.Callable);
     method public androidx.room.RoomDatabase.Builder enableMultiInstanceInvalidation();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigration();
     method public androidx.room.RoomDatabase.Builder fallbackToDestructiveMigrationFrom(int...);
diff --git a/room/runtime/src/main/java/androidx/room/RoomDatabase.java b/room/runtime/src/main/java/androidx/room/RoomDatabase.java
index 8595be4..6dc4f75 100644
--- a/room/runtime/src/main/java/androidx/room/RoomDatabase.java
+++ b/room/runtime/src/main/java/androidx/room/RoomDatabase.java
@@ -42,6 +42,7 @@
 import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory;
 
 import java.io.File;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -56,6 +57,8 @@
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
+import kotlin.Suppress;
+
 /**
  * Base class for all Room databases. All classes that are annotated with {@link Database} must
  * extend this class.
@@ -565,6 +568,7 @@
 
         private String mCopyFromAssetPath;
         private File mCopyFromFile;
+        private Callable mInputStreamCallable;
 
         Builder(@NonNull Context context, @NonNull Class klass, @Nullable String name) {
             mContext = context;
@@ -625,6 +629,34 @@
         }
 
         /**
+         * Configures Room to create and open the database using an {@link InputStream}.
+         * 

+ * Provide Room with a pre-packaged database via an {@link InputStream}. This is useful + * for processing compressed files. Room does not open the pre-packaged database, instead + * it copies it into the internal app database folder, and then opens it. The given file + * must be accessible and the right permissions must be granted for Room to copy the file. + *

+ * The pre-packaged database schema will be validated. It might be best to create your + * pre-packaged database schema utilizing the exported schema files generated when + * {@link Database#exportSchema()} is enabled. + *

+ * This method is not supported for an in memory database {@link Builder}. The underlying + * {@link InputStream} will be closed. + * + * @param inputStreamCallable A callable that returns an InputStream from which to copy + * the database. + * + * @return This {@link Builder} instance. + */ + @SuppressLint("BuilderSetStyle") + @NonNull + public Builder createFromInputStream( + @NonNull Callable inputStreamCallable) { + mInputStreamCallable = inputStreamCallable; + return this; + } + + /** * Sets the database factory. If not set, it defaults to * {@link FrameworkSQLiteOpenHelperFactory}. * @@ -919,18 +951,24 @@ mFactory = new FrameworkSQLiteOpenHelperFactory(); } - if (mCopyFromAssetPath != null || mCopyFromFile != null) { + if (mCopyFromAssetPath != null || mCopyFromFile != null + || mInputStreamCallable != null) { if (mName == null) { throw new IllegalArgumentException("Cannot create from asset or file for an " + "in-memory database."); } - if (mCopyFromAssetPath != null && mCopyFromFile != null) { - throw new IllegalArgumentException("Both createFromAsset() and " - + "createFromFile() was called on this Builder but the database can " - + "only be created using one of the two configurations."); + + final int copyConfigurations = (mCopyFromAssetPath == null ? 0 : 1) + + (mCopyFromFile == null ? 0 : 1) + + (mInputStreamCallable == null ? 0 : 1); + if (copyConfigurations != 1) { + throw new IllegalArgumentException("More than one of createFromAsset(), " + + "createFromInputStream(), and createFromFile() were called on this " + + "Builder, but the database can only be created using one of the " + + "three configurations."); } mFactory = new SQLiteCopyOpenHelperFactory(mCopyFromAssetPath, mCopyFromFile, - mFactory); + mInputStreamCallable, mFactory); } DatabaseConfiguration configuration = new DatabaseConfiguration(

diff --git a/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelper.java b/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelper.java
index f8240ce..f6c9b9c 100644
--- a/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelper.java
+++ b/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelper.java
@@ -33,9 +33,13 @@
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.InvalidObjectException;
 import java.nio.channels.Channels;
 import java.nio.channels.FileChannel;
 import java.nio.channels.ReadableByteChannel;
+import java.util.concurrent.Callable;
+import java.util.zip.GZIPInputStream;
 
 /**
  * An open helper that will copy & open a pre-populated database if it doesn't exists in internal
@@ -49,6 +53,8 @@
     private final String mCopyFromAssetPath;
     @Nullable
     private final File mCopyFromFile;
+    @Nullable
+    private final Callable mInputStreamCallable;
     private final int mDatabaseVersion;
     @NonNull
     private final SupportSQLiteOpenHelper mDelegate;
@@ -61,11 +67,13 @@
             @NonNull Context context,
             @Nullable String copyFromAssetPath,
             @Nullable File copyFromFile,
+            @Nullable Callable inputStreamCallable,
             int databaseVersion,
             @NonNull SupportSQLiteOpenHelper supportSQLiteOpenHelper) {
         mContext = context;
         mCopyFromAssetPath = copyFromAssetPath;
         mCopyFromFile = copyFromFile;
+        mInputStreamCallable = inputStreamCallable;
         mDatabaseVersion = databaseVersion;
         mDelegate = supportSQLiteOpenHelper;
     }
@@ -178,6 +186,14 @@
             input = Channels.newChannel(mContext.getAssets().open(mCopyFromAssetPath));
         } else if (mCopyFromFile != null) {
             input = new FileInputStream(mCopyFromFile).getChannel();
+        } else if (mInputStreamCallable != null) {
+            final InputStream inputStream;
+            try {
+                inputStream = mInputStreamCallable.call();
+            } catch (Exception e) {
+                throw new IllegalStateException("inputStreamCallable exception on call", e);
+            }
+            input = Channels.newChannel(inputStream);
         } else {
             throw new IllegalStateException("copyFromAssetPath and copyFromFile == null!");
         }
diff --git a/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java b/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java
index 22178e8..df9bcb6 100644
--- a/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java
+++ b/room/runtime/src/main/java/androidx/room/SQLiteCopyOpenHelperFactory.java
@@ -21,6 +21,8 @@
 import androidx.sqlite.db.SupportSQLiteOpenHelper;
 
 import java.io.File;
+import java.io.InputStream;
+import java.util.concurrent.Callable;
 
 /**
  * Implementation of {@link SupportSQLiteOpenHelper.Factory} that creates
@@ -32,24 +34,29 @@
     private final String mCopyFromAssetPath;
     @Nullable
     private final File mCopyFromFile;
+    private final Callable mInputStreamCallable;
     @NonNull
     private final SupportSQLiteOpenHelper.Factory mDelegate;
 
     SQLiteCopyOpenHelperFactory(
             @Nullable String copyFromAssetPath,
             @Nullable File copyFromFile,
+            @Nullable Callable inputStreamCallable,
             @NonNull SupportSQLiteOpenHelper.Factory factory) {
         mCopyFromAssetPath = copyFromAssetPath;
         mCopyFromFile = copyFromFile;
+        mInputStreamCallable = inputStreamCallable;
         mDelegate = factory;
     }
 
+    @NonNull
     @Override
     public SupportSQLiteOpenHelper create(SupportSQLiteOpenHelper.Configuration configuration) {
         return new SQLiteCopyOpenHelper(
                 configuration.context,
                 mCopyFromAssetPath,
                 mCopyFromFile,
+                mInputStreamCallable,
                 configuration.callback.version,
                 mDelegate.create(configuration));
     }
diff --git a/room/runtime/src/test/java/androidx/room/BuilderTest.java b/room/runtime/src/test/java/androidx/room/BuilderTest.java
index 6252178..ae6d83b 100644
--- a/room/runtime/src/test/java/androidx/room/BuilderTest.java
+++ b/room/runtime/src/test/java/androidx/room/BuilderTest.java
@@ -442,8 +442,9 @@
         }
         assertThat(exception, instanceOf(IllegalArgumentException.class));
         assertThat(exception.getMessage(),
-                containsString("Both createFromAsset() and createFromFile() was called on "
-                        + "this Builder"));
+                containsString(
+                        "More than one of createFromAsset(), createFromInputStream(), and "
+                                + "createFromFile() were called on this Builder"));
     }
 
     @Test
diff --git a/room/runtime/src/test/java/androidx/room/SQLiteCopyOpenHelperTest.kt b/room/runtime/src/test/java/androidx/room/SQLiteCopyOpenHelperTest.kt
index 91d15b3..46ee269 100644
--- a/room/runtime/src/test/java/androidx/room/SQLiteCopyOpenHelperTest.kt
+++ b/room/runtime/src/test/java/androidx/room/SQLiteCopyOpenHelperTest.kt
@@ -207,6 +207,7 @@
             context,
             copyFromAssetFile.name,
             null,
+            null,
             DB_VERSION,
             delegate
         ).apply { setDatabaseConfiguration(configuration) }