Merge "[WebKit] Add handler to load files from app internal storage" into androidx-master-dev
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
index 70a2b11..06a7a6f 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderIntegrationTest.java
@@ -17,8 +17,10 @@
 package androidx.webkit;
 
 import static androidx.webkit.WebViewAssetLoader.AssetsPathHandler;
+import static androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler;
 import static androidx.webkit.WebViewAssetLoader.ResourcesPathHandler;
 
+import android.content.Context;;
 import android.net.Uri;
 import android.webkit.WebResourceRequest;
 import android.webkit.WebResourceResponse;
@@ -27,6 +29,7 @@
 import androidx.test.filters.MediumTest;
 import androidx.test.rule.ActivityTestRule;
 import androidx.test.runner.AndroidJUnit4;
+import androidx.webkit.internal.AssetHelper;
 
 import org.junit.After;
 import org.junit.Assert;
@@ -35,6 +38,8 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.File;
+
 @RunWith(AndroidJUnit4.class)
 public class WebViewAssetLoaderIntegrationTest {
     private static final String TAG = "WebViewAssetLoaderIntegrationTest";
@@ -43,6 +48,12 @@
     public final ActivityTestRule<WebViewTestActivity> mActivityRule =
                                     new ActivityTestRule<>(WebViewTestActivity.class);
 
+    private static final String TEST_INTERNAL_STORAGE_DIR = "app_public/";
+    private static final String TEST_INTERNAL_STORAGE_FILE =
+            TEST_INTERNAL_STORAGE_DIR + "html/test_with_title.html";
+    private static final String TEST_HTML_CONTENT =
+            "<head><title>WebViewAssetLoaderTest</title></head>";
+
     private WebViewOnUiThread mOnUiThread;
 
     private static class AssetLoadingWebViewClient extends WebViewOnUiThread.WaitForLoadedClient {
@@ -76,6 +87,10 @@
         if (mOnUiThread != null) {
             mOnUiThread.cleanUp();
         }
+
+        Context context = mActivityRule.getActivity();
+        WebkitUtils.recursivelyDeleteFile(
+                new File(AssetHelper.getDataDir(context), TEST_INTERNAL_STORAGE_DIR));
     }
 
     @Test
@@ -125,4 +140,34 @@
 
         Assert.assertEquals("WebViewAssetLoaderTest", mOnUiThread.getTitle());
     }
+
+    @Test
+    @MediumTest
+    public void testAppInternalStorageHosting() throws Exception {
+        final WebViewTestActivity activity = mActivityRule.getActivity();
+
+        File dataDir = AssetHelper.getDataDir(activity);
+        WebkitUtils.writeToFile(new File(dataDir, TEST_INTERNAL_STORAGE_FILE), TEST_HTML_CONTENT);
+
+        InternalStoragePathHandler handler = new InternalStoragePathHandler(activity,
+                new File(dataDir, TEST_INTERNAL_STORAGE_DIR));
+        WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+                .addPathHandler("/data/public/", handler)
+                .build();
+
+        mOnUiThread.setWebViewClient(new AssetLoadingWebViewClient(mOnUiThread, assetLoader));
+
+        String url = new Uri.Builder()
+                        .scheme("https")
+                        .authority(WebViewAssetLoader.DEFAULT_DOMAIN)
+                        .appendPath("data")
+                        .appendPath("public")
+                        .appendPath("html")
+                        .appendPath("test_with_title.html")
+                        .build()
+                        .toString();
+        mOnUiThread.loadUrlAndWaitForCompletion(url);
+
+        Assert.assertEquals("WebViewAssetLoaderTest", mOnUiThread.getTitle());
+    }
 }
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
index 029425a..db29999 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebViewAssetLoaderTest.java
@@ -16,11 +16,13 @@
 
 package androidx.webkit;
 
+import android.content.Context;
 import android.content.ContextWrapper;
 import android.net.Uri;
 import android.webkit.WebResourceResponse;
 
 import static androidx.webkit.WebViewAssetLoader.AssetsPathHandler;
+import static androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler;
 import static androidx.webkit.WebViewAssetLoader.PathHandler;
 import static androidx.webkit.WebViewAssetLoader.ResourcesPathHandler;
 
@@ -37,6 +39,7 @@
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
@@ -215,6 +218,84 @@
         assertResponse(response, testHtmlContents);
     }
 
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_entireDataDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = AssetHelper.getDataDir(context);
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_entireCacheDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = context.getCacheDir();
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_databasesDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = new File(AssetHelper.getDataDir(context), "databases/");
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_libDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = new File(AssetHelper.getDataDir(context), "lib/");
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_webViewDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = new File(AssetHelper.getDataDir(context), "app_webview");
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @SmallTest
+    @Test(expected = IllegalArgumentException.class)
+    public void testCreateInternalStorageHandler_sharedPrefsDir() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = new File(AssetHelper.getDataDir(context), "/shared_prefs/");
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+    }
+
+    @Test
+    @SmallTest
+    public void testHostInternalStorageHandler_invalidAccess() throws Throwable {
+        Context context = ApplicationProvider.getApplicationContext();
+        File testDir = new File(AssetHelper.getDataDir(context), "/public/");
+        PathHandler handler =
+                new InternalStoragePathHandler(context, testDir);
+        WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+                                                      .addPathHandler("/public-data/", handler)
+                                                      .build();
+
+        WebResourceResponse response = assetLoader.shouldInterceptRequest(
+                Uri.parse("https://appassets.androidplatform.net/public-data/../test.html"));
+        Assert.assertNull(
+                "should be null since it tries to access a file outside the mounted directory",
+                response.getData());
+
+        response = assetLoader.shouldInterceptRequest(
+                Uri.parse("https://appassets.androidplatform.net/public-data/html/test.html"));
+        Assert.assertNull(
+                "should be null as it accesses a non-existent file under the mounted directory",
+                response.getData());
+    }
+
     @Test
     @SmallTest
     public void testMultiplePathHandlers() throws Throwable {
diff --git a/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java b/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
index 28d0633..a7994fc 100644
--- a/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
+++ b/webkit/src/androidTest/java/androidx/webkit/WebkitUtils.java
@@ -25,6 +25,9 @@
 
 import org.junit.Assume;
 
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -211,6 +214,42 @@
         }
     }
 
+    /**
+     * Write a string to a file, and create the whole parent directories if they don't exist.
+     */
+    public static void writeToFile(File file, String content)
+                  throws IOException {
+        file.getParentFile().mkdirs();
+        FileOutputStream fos = new FileOutputStream(file);
+        try {
+            fos.write(content.getBytes("utf-8"));
+        } finally {
+            fos.close();
+        }
+    }
+
+    /**
+     * Delete the given File and (if it's a directory) everything within it.
+     * @param currentFile The file or directory to delete. Does not need to exist.
+     * @return Whether currentFile does not exist afterwards.
+     */
+    public static boolean recursivelyDeleteFile(File currentFile) {
+        if (!currentFile.exists()) {
+            return true;
+        }
+        if (currentFile.isDirectory()) {
+            File[] files = currentFile.listFiles();
+            if (files != null) {
+                for (File file : files) {
+                    recursivelyDeleteFile(file);
+                }
+            }
+        }
+
+        boolean ret = currentFile.delete();
+        return ret;
+    }
+
     // Do not instantiate this class.
     private WebkitUtils() {}
 }
diff --git a/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java b/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
index 9ecf3f0..5696e8a9 100644
--- a/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
+++ b/webkit/src/androidTest/java/androidx/webkit/internal/AssetHelperTest.java
@@ -21,15 +21,19 @@
 import android.util.Log;
 
 import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.MediumTest;
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
+import androidx.webkit.WebkitUtils;
 
+import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 
@@ -39,11 +43,19 @@
 
     private static final String TEST_STRING = "Just a test";
     private AssetHelper mAssetHelper;
+    private File mInternalStorageTestDir;
 
     @Before
     public void setup() {
         Context context = InstrumentationRegistry.getContext();
         mAssetHelper = new AssetHelper(context);
+        mInternalStorageTestDir = new File(context.getFilesDir(), "test_dir");
+        mInternalStorageTestDir.mkdirs();
+    }
+
+    @After
+    public void tearDown() {
+        WebkitUtils.recursivelyDeleteFile(mInternalStorageTestDir);
     }
 
     @Test
@@ -114,6 +126,52 @@
                           mAssetHelper.openAsset(Uri.parse("/android_asset/test.txt")));
     }
 
+    @Test
+    @MediumTest
+    public void testOpenFileFromInternalStorage() throws Throwable {
+        File testFile = new File(mInternalStorageTestDir, "some_file.txt");
+        WebkitUtils.writeToFile(testFile, TEST_STRING);
+
+        InputStream stream = AssetHelper.openFile(testFile);
+        Assert.assertNotNull("Should be able to open \"" + testFile + "\" from internal storage",
+                stream);
+        Assert.assertEquals(readAsString(stream), TEST_STRING);
+    }
+
+    @Test
+    @MediumTest
+    public void testOpenNonExistingFileInInternalStorage() throws Throwable {
+        File testFile = new File(mInternalStorageTestDir, "some/path/to/non_exist_file.txt");
+        InputStream stream = AssetHelper.openFile(testFile);
+        Assert.assertNull("Should not be able to open a non existing file from internal storage",
+                stream);
+    }
+
+    @Test
+    @SmallTest
+    public void testIsCanonicalChildOf() throws Throwable {
+        // Two files are used for testing :
+        // "/some/path/to/file_1.txt" and "/some/path/file_2.txt"
+
+        File parent = new File(mInternalStorageTestDir, "/some/path/");
+        File child = new File(parent, "/to/./file_1.txt");
+        boolean res = AssetHelper.isCanonicalChildOf(parent, child);
+        Assert.assertTrue(
+                "/to/./\"file_1.txt\" is in a subdirectory of \"/some/path/\"", res);
+
+        parent = new File(mInternalStorageTestDir, "/some/path/");
+        child = new File(parent, "/to/../file_2.txt");
+        res = AssetHelper.isCanonicalChildOf(parent, child);
+        Assert.assertTrue(
+                "/to/../\"file_2.txt\" is in a subdirectory of \"/some/path/\"", res);
+
+        parent = new File(mInternalStorageTestDir, "/some/path/to");
+        child = new File(parent, "/../file_2.txt");
+        res = AssetHelper.isCanonicalChildOf(parent, child);
+        Assert.assertFalse(
+                "/../\"file_2.txt\" is not in a subdirectory of \"/some/path/to/\"", res);
+    }
+
     private static String readAsString(InputStream is) {
         ByteArrayOutputStream os = new ByteArrayOutputStream();
         byte[] buffer = new byte[512];
diff --git a/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java b/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
index fa064fc..d74bde7 100644
--- a/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
+++ b/webkit/src/main/java/androidx/webkit/WebViewAssetLoader.java
@@ -18,24 +18,29 @@
 
 import android.content.Context;
 import android.net.Uri;
+import android.util.Log;
 import android.webkit.WebResourceResponse;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
 import androidx.annotation.VisibleForTesting;
 import androidx.annotation.WorkerThread;
 import androidx.webkit.internal.AssetHelper;
 
+import java.io.File;
+import java.io.IOException;
 import java.io.InputStream;
 import java.net.URLConnection;
 import java.util.ArrayList;
 import java.util.List;
 
 /**
- * Helper class to load files including application's static assets and resources using http(s)://
- * URLs inside a {@link android.webkit.WebView} class.
- * Loading assets and resources using web-like URLs is desirable as it is compatible with the
- * Same-Origin policy.
+ * Helper class to load local files including application's static assets and resources using
+ * http(s):// URLs inside a {@link android.webkit.WebView} class.
+ * Loading local files using web-like URLs instead of {@code "file://"} is desirable as it is
+ * compatible with the Same-Origin policy.
  *
  * <p>
  * For more context about application's assets and resources and how to normally access them please
@@ -209,6 +214,113 @@
     }
 
     /**
+     * Handler class to open files from application internal storage.
+     * For more information about android storage please refer to
+     * <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
+     * Docs: Data and file storage overview</a>.
+     * <p>
+     * To avoid leaking user or app data to the web, make sure to choose {@code directory}
+     * carefully, and assume any file under this directory could be accessed by any web page subject
+     * to same-origin rules.
+     * @hide
+     */
+    // TODO(b/132880733) unhide the API when it's ready.
+    @RestrictTo(Scope.LIBRARY_GROUP_PREFIX)
+    public static final class InternalStoragePathHandler implements PathHandler {
+        /**
+         * Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
+         * handler. They are forbidden as they often contain sensitive information.
+         */
+        public static final String[] FORBIDDEN_DATA_DIRS =
+                new String[] {"app_webview/", "databases/", "lib/", "shared_prefs/", "code_cache/"};
+
+        @NonNull private final File mDirectory;
+
+        /**
+         * Creates PathHandler for app's internal storage.
+         * The directory to be exposed must be inside either the application's internal data
+         * directory {@link context#getDataDir} or cache directory {@link context#getCacheDir}.
+         * External storage is not supported for security reasons, as other apps with
+         * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
+         * files.
+         * <p>
+         * Exposing the entire data or cache directory is not permitted, to avoid accidentally
+         * exposing sensitive application files to the web. Certain existing directories are also
+         * not permitted, such as {@link FORBIDDEN_DATA_DIRS}, as they are often sensitive.
+         * <p>
+         * The application should typically use a dedicated subdirectory for the files it intends to
+         * expose and keep them separate from other files.
+         *
+         * @param context {@link Context} that is used to access app's internal storage.
+         * @param directory the absolute path of the exposed app internal storage directory from
+         *                  which files can be loaded.
+         * @throws IllegalArgumentException if the directory is not allowed.
+         */
+        public InternalStoragePathHandler(@NonNull Context context, @NonNull File directory) {
+            if (!isAllowedInternalStorageDir(context, directory)) {
+                throw new IllegalArgumentException("The given directory \"" + directory
+                        + "\" doesn't exist under an allowed app internal storage directory");
+            }
+            mDirectory = directory;
+        }
+
+        private static boolean isAllowedInternalStorageDir(@NonNull Context context,
+                @NonNull File dir) {
+            try {
+                String dirPath = AssetHelper.getCanonicalPath(dir);
+                String cacheDirPath = AssetHelper.getCanonicalPath(context.getCacheDir());
+                String dataDirPath = AssetHelper.getCanonicalPath(AssetHelper.getDataDir(context));
+                // dir has to be a subdirectory of data or cache dir.
+                if (!dirPath.startsWith(cacheDirPath) && !dirPath.startsWith(dataDirPath)) {
+                    return false;
+                }
+                // dir cannot be the entire cache or data dir.
+                if (dirPath.equals(cacheDirPath) || dirPath.equals(dataDirPath)) {
+                    return false;
+                }
+                // dir cannot be a subdirectory of any forbidden data dir.
+                for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
+                    if (dirPath.startsWith(dataDirPath + forbiddenPath)) return false;
+                }
+                return true;
+            } catch (IOException e) {
+                return false;
+            }
+        }
+
+        /**
+         * Opens the requested file from the exposed data directory.
+         * <p>
+         * The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
+         * requested file cannot be found or is outside the mounted directory a
+         * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
+         * returned instead of {@code null}. This saves the time of falling back to network and
+         * trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
+         * {@code null} {@link InputStream} will be received as an HTTP response with status code
+         * {@code 404} and no body.
+         *
+         * @param path the suffix path to be handled.
+         * @return {@link WebResourceResponse} for the requested file.
+         */
+        @Override
+        @WorkerThread
+        @NonNull
+        public WebResourceResponse handle(@NonNull String path) {
+            File file = new File(mDirectory, path);
+            InputStream is = null;
+            if (AssetHelper.isCanonicalChildOf(mDirectory, file)) {
+                is = AssetHelper.openFile(file);
+            } else {
+                Log.e(TAG, "The requested file: " + path + " is outside the mounted directory: "
+                         + mDirectory);
+            }
+            String mimeType = URLConnection.guessContentTypeFromName(path);
+            return new WebResourceResponse(mimeType, null, is);
+        }
+    }
+
+
+    /**
      * Matches URIs on the form: {@code "http(s)://authority/path/**"}, HTTPS is always enabled.
      *
      * <p>
diff --git a/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java b/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
index d2f2dfe..8294ab2 100644
--- a/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
+++ b/webkit/src/main/java/androidx/webkit/internal/AssetHelper.java
@@ -20,12 +20,15 @@
 import android.content.res.AssetManager;
 import android.content.res.Resources;
 import android.net.Uri;
+import android.os.Build;
 import android.util.Log;
 import android.util.TypedValue;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
@@ -124,4 +127,73 @@
             return null;
         }
     }
+
+    /**
+     * Open an {@code InputStream} for a file in application data directories.
+     *
+     * @param file The the file to be opened.
+     * @return An {@code InputStream} for the requested file or {@code null} if an error happens.
+     */
+    @Nullable
+    public static InputStream openFile(@NonNull File file) {
+        try {
+            FileInputStream fis = new FileInputStream(file);
+            return handleSvgzStream(Uri.parse(file.getPath()), fis);
+        } catch (IOException e) {
+            Log.e(TAG, "Error opening the requested file " + file, e);
+            return null;
+        }
+    }
+
+    /**
+     * Util method to test if the a given file is a child of the given parent directory.
+     * It uses canonical paths to make sure to resolve any symlinks, {@code "../"}, {@code "./"}
+     * ... etc in the given paths.
+     *
+     * @param parent the parent directory.
+     * @param child the child file.
+     * @return {@code true} if the canonical path of the given {@code child} starts with the
+     *         canonical path of the given {@code parent}, {@code false} otherwise.
+     */
+    public static boolean isCanonicalChildOf(@NonNull File parent, @NonNull File child) {
+        try {
+            String parentCanonicalPath = parent.getCanonicalPath();
+            String childCanonicalPath = child.getCanonicalPath();
+
+            if (!parentCanonicalPath.endsWith("/")) parentCanonicalPath += "/";
+
+            return childCanonicalPath.startsWith(parentCanonicalPath);
+        } catch (IOException e) {
+            Log.e(TAG, "Error getting the canonical path of file", e);
+            return false;
+        }
+    }
+
+    /**
+     * Get the canonical path of the given directory with a slash {@code "/"} at the end.
+     */
+    @NonNull
+    public static String getCanonicalPath(@NonNull File file) throws IOException {
+        String path = file.getCanonicalPath();
+        if (!path.endsWith("/")) path += "/";
+        return path;
+    }
+
+    /**
+     * Get the data dir for an application.
+     *
+     * @param context the {@link Context} used to get the data dir.
+     * @return data dir {@link File} for that app.
+     */
+    @NonNull
+    public static File getDataDir(@NonNull Context context) {
+        // Context#getDataDir is only available in APIs >= 24.
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+            return context.getDataDir();
+        } else {
+            // For APIs < 24 cache dir is created under the data dir.
+            return context.getCacheDir().getParentFile();
+        }
+    }
+
 }