diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 5442f3c564c..fdbdef51b79 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -454,6 +454,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/view/FlutterView.java FILE: ../../../flutter/shell/platform/android/io/flutter/view/ResourceCleaner.java FILE: ../../../flutter/shell/platform/android/io/flutter/view/ResourceExtractor.java FILE: ../../../flutter/shell/platform/android/io/flutter/view/ResourcePaths.java +FILE: ../../../flutter/shell/platform/android/io/flutter/view/ResourceUpdater.java FILE: ../../../flutter/shell/platform/android/io/flutter/view/TextureRegistry.java FILE: ../../../flutter/shell/platform/android/io/flutter/view/VsyncWaiter.java FILE: ../../../flutter/shell/platform/android/library_loader.cc diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index aad27b2f9dd..73e2bac819a 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -140,6 +140,7 @@ java_library("flutter_shell_java") { "io/flutter/view/ResourceCleaner.java", "io/flutter/view/ResourceExtractor.java", "io/flutter/view/ResourcePaths.java", + "io/flutter/view/ResourceUpdater.java", "io/flutter/view/TextureRegistry.java", "io/flutter/view/VsyncWaiter.java", ] diff --git a/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java b/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java index dad63b9e1a8..a78a805797d 100644 --- a/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java +++ b/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java @@ -166,11 +166,17 @@ public final class FlutterActivityDelegate if (loadIntent(activity.getIntent())) { return; } + if (!flutterView.getFlutterNativeView().isApplicationRunning()) { String appBundlePath = FlutterMain.findAppBundlePath(activity.getApplicationContext()); if (appBundlePath != null) { FlutterRunArguments arguments = new FlutterRunArguments(); - arguments.bundlePath = appBundlePath; + ArrayList bundlePaths = new ArrayList(); + if (FlutterMain.getUpdateInstallationPath() != null) { + bundlePaths.add(FlutterMain.getUpdateInstallationPath()); + } + bundlePaths.add(appBundlePath); + arguments.bundlePaths = bundlePaths.toArray(new String[0]); arguments.entrypoint = "main"; flutterView.runFromBundle(arguments); } @@ -337,7 +343,12 @@ public final class FlutterActivityDelegate } if (!flutterView.getFlutterNativeView().isApplicationRunning()) { FlutterRunArguments args = new FlutterRunArguments(); - args.bundlePath = appBundlePath; + ArrayList bundlePaths = new ArrayList(); + if (FlutterMain.getUpdateInstallationPath() != null) { + bundlePaths.add(FlutterMain.getUpdateInstallationPath()); + } + bundlePaths.add(appBundlePath); + args.bundlePaths = bundlePaths.toArray(new String[0]); args.entrypoint = "main"; flutterView.runFromBundle(args); } diff --git a/shell/platform/android/io/flutter/view/FlutterMain.java b/shell/platform/android/io/flutter/view/FlutterMain.java index 8223f3c4c36..d59adb4cc92 100644 --- a/shell/platform/android/io/flutter/view/FlutterMain.java +++ b/shell/platform/android/io/flutter/view/FlutterMain.java @@ -77,6 +77,7 @@ public class FlutterMain { private static String sFlutterAssetsDir = DEFAULT_FLUTTER_ASSETS_DIR; private static boolean sInitialized = false; + private static ResourceUpdater sResourceUpdater; private static ResourceExtractor sResourceExtractor; private static boolean sIsPrecompiledAsBlobs; private static boolean sIsPrecompiledAsSharedLibrary; @@ -254,6 +255,21 @@ public class FlutterMain { Context context = applicationContext; new ResourceCleaner(context).start(); + Bundle metaData = null; + try { + metaData = context.getPackageManager().getApplicationInfo( + context.getPackageName(), PackageManager.GET_META_DATA).metaData; + + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Unable to read application info", e); + } + + if (metaData != null && metaData.getBoolean("DynamicUpdates")) { + sResourceUpdater = new ResourceUpdater(context); + sResourceUpdater.startUpdateDownloadOnce(); + sResourceUpdater.waitForDownloadCompletion(); + } + sResourceExtractor = new ResourceExtractor(context); String icuAssetPath = SHARED_ASSET_DIR + File.separator + SHARED_ASSET_ICU_DATA; @@ -321,6 +337,10 @@ public class FlutterMain { return appBundle.exists() ? appBundle.getPath() : null; } + public static String getUpdateInstallationPath() { + return sResourceUpdater == null ? null : sResourceUpdater.getUpdateInstallationPath(); + } + /** * Returns the file name for the given asset. * The returned file name can be used to access the asset in the APK diff --git a/shell/platform/android/io/flutter/view/ResourceExtractor.java b/shell/platform/android/io/flutter/view/ResourceExtractor.java index 840977f3eaf..67fc1aa19e8 100644 --- a/shell/platform/android/io/flutter/view/ResourceExtractor.java +++ b/shell/platform/android/io/flutter/view/ResourceExtractor.java @@ -11,15 +11,22 @@ import android.content.res.AssetManager; import android.os.AsyncTask; import android.util.Log; import io.flutter.util.PathUtils; +import org.json.JSONException; +import org.json.JSONObject; import java.io.*; import java.util.Collection; import java.util.HashSet; +import java.util.Scanner; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; /** - * A class to intialize the native code. + * A class to initialize the native code. **/ class ResourceExtractor { private static final String TAG = "ResourceExtractor"; @@ -33,18 +40,46 @@ class ResourceExtractor { private void extractResources() { final File dataDir = new File(PathUtils.getDataDirectory(mContext)); - final String timestamp = checkTimestamp(dataDir); - if (timestamp != null) { - deleteFiles(); + JSONObject updateManifest = readUpdateManifest(); + if (!validateUpdateManifest(updateManifest)) { + updateManifest = null; } + final String timestamp = checkTimestamp(dataDir, updateManifest); + if (timestamp == null) { + return; + } + + deleteFiles(); + + if (updateManifest != null) { + if (!extractUpdate(dataDir)) { + return; + } + } + + if (!extractAPK(dataDir)) { + return; + } + + if (timestamp != null) { + try { + new File(dataDir, timestamp).createNewFile(); + } catch (IOException e) { + Log.w(TAG, "Failed to write resource timestamp"); + } + } + } + + /// Returns true if successfully unpacked APK resources, + /// otherwise deletes all resources and returns false. + private boolean extractAPK(File dataDir) { final AssetManager manager = mContext.getResources().getAssets(); byte[] buffer = null; for (String asset : mResources) { try { final File output = new File(dataDir, asset); - if (output.exists()) { continue; } @@ -62,28 +97,99 @@ class ResourceExtractor { while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) { os.write(buffer, 0, count); } + os.flush(); + Log.i(TAG, "Extracted baseline resource " + asset); } } + } catch (FileNotFoundException fnfe) { continue; + } catch (IOException ioe) { Log.w(TAG, "Exception unpacking resources: " + ioe.getMessage()); deleteFiles(); - return; + return false; } } - if (timestamp != null) { - try { - new File(dataDir, timestamp).createNewFile(); - } catch (IOException e) { - Log.w(TAG, "Failed to write resource timestamp"); - } - } + return true; } - private String checkTimestamp(File dataDir) { + /// Returns true if successfully unpacked update resources or if there is no update, + /// otherwise deletes all resources and returns false. + private boolean extractUpdate(File dataDir) { + if (FlutterMain.getUpdateInstallationPath() == null) { + return true; + } + + final File updateFile = new File(FlutterMain.getUpdateInstallationPath()); + if (!updateFile.exists()) { + return true; + } + + ZipFile zipFile; + try { + zipFile = new ZipFile(updateFile); + + } catch (ZipException e) { + Log.w(TAG, "Exception unpacking resources: " + e.getMessage()); + deleteFiles(); + return false; + + } catch (IOException e) { + Log.w(TAG, "Exception unpacking resources: " + e.getMessage()); + deleteFiles(); + return false; + } + + byte[] buffer = null; + for (String asset : mResources) { + ZipEntry entry = zipFile.getEntry(asset); + if (entry == null) { + continue; + } + + final File output = new File(dataDir, asset); + if (output.exists()) { + continue; + } + if (output.getParentFile() != null) { + output.getParentFile().mkdirs(); + } + + try (InputStream is = zipFile.getInputStream(entry)) { + try (OutputStream os = new FileOutputStream(output)) { + if (buffer == null) { + buffer = new byte[BUFFER_SIZE]; + } + + int count = 0; + while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) { + os.write(buffer, 0, count); + } + + os.flush(); + Log.i(TAG, "Extracted override resource " + asset); + } + + } catch (FileNotFoundException fnfe) { + continue; + + } catch (IOException ioe) { + Log.w(TAG, "Exception unpacking resources: " + ioe.getMessage()); + deleteFiles(); + return false; + } + } + + return true; + } + + // Returns null if extracted resources are found and match the current APK version + // and update version if any, otherwise returns the current APK and update version. + private String checkTimestamp(File dataDir, JSONObject updateManifest) { + PackageManager packageManager = mContext.getPackageManager(); PackageInfo packageInfo = null; @@ -100,20 +206,119 @@ class ResourceExtractor { String expectedTimestamp = TIMESTAMP_PREFIX + packageInfo.versionCode + "-" + packageInfo.lastUpdateTime; + if (updateManifest != null) { + String baselineVersion = updateManifest.optString("baselineVersion", null); + if (baselineVersion == null) { + Log.w(TAG, "Invalid update manifest: baselineVersion"); + } + + String updateVersion = updateManifest.optString("updateVersion", null); + if (updateVersion == null) { + Log.w(TAG, "Invalid update manifest: updateVersion"); + } + + if (baselineVersion != null && updateVersion != null) { + if (!baselineVersion.equals(Integer.toString(packageInfo.versionCode))) { + Log.w(TAG, "Outdated update file for " + packageInfo.versionCode); + } else { + final File updateFile = new File(FlutterMain.getUpdateInstallationPath()); + expectedTimestamp += "-" + updateVersion + "-" + updateFile.lastModified(); + } + } + } + final String[] existingTimestamps = getExistingTimestamps(dataDir); if (existingTimestamps == null) { - return null; + Log.i(TAG, "No extracted resources found"); + return expectedTimestamp; + } + + if (existingTimestamps.length == 1) { + Log.i(TAG, "Found extracted resources " + existingTimestamps[0]); } if (existingTimestamps.length != 1 || !expectedTimestamp.equals(existingTimestamps[0])) { + Log.i(TAG, "Resource version mismatch " + expectedTimestamp); return expectedTimestamp; } return null; } + /// Returns true if the downloaded update file was indeed built for this APK. + private boolean validateUpdateManifest(JSONObject updateManifest) { + if (updateManifest == null) { + return false; + } + + String baselineChecksum = updateManifest.optString("baselineChecksum", null); + if (baselineChecksum == null) { + Log.w(TAG, "Invalid update manifest: baselineChecksum"); + return false; + } + + final AssetManager manager = mContext.getResources().getAssets(); + try (InputStream is = manager.open("flutter_assets/isolate_snapshot_data")) { + CRC32 checksum = new CRC32(); + + int count = 0; + byte[] buffer = new byte[BUFFER_SIZE]; + while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) { + checksum.update(buffer, 0, count); + } + + if (!baselineChecksum.equals(String.valueOf(checksum.getValue()))) { + Log.w(TAG, "Mismatched update file for APK"); + return false; + } + + return true; + + } catch (IOException e) { + Log.w(TAG, "Could not read APK: " + e); + return false; + } + } + + /// Returns null if no update manifest is found. + private JSONObject readUpdateManifest() { + if (FlutterMain.getUpdateInstallationPath() == null) { + return null; + } + + File updateFile = new File(FlutterMain.getUpdateInstallationPath()); + if (!updateFile.exists()) { + return null; + } + + try { + ZipFile zipFile = new ZipFile(updateFile); + ZipEntry entry = zipFile.getEntry("manifest.json"); + if (entry == null) { + Log.w(TAG, "Invalid update file: " + updateFile); + return null; + } + + // Read and parse the entire JSON file as single operation. + Scanner scanner = new Scanner(zipFile.getInputStream(entry)); + return new JSONObject(scanner.useDelimiter("\\A").next()); + + } catch (ZipException e) { + Log.w(TAG, "Invalid update file: " + e); + return null; + + } catch (IOException e) { + Log.w(TAG, "Invalid update file: " + e); + return null; + + } catch (JSONException e) { + Log.w(TAG, "Invalid update file: " + e); + return null; + } + } + @Override protected Void doInBackground(Void... unused) { extractResources(); diff --git a/shell/platform/android/io/flutter/view/ResourceUpdater.java b/shell/platform/android/io/flutter/view/ResourceUpdater.java new file mode 100644 index 00000000000..d627ad0524a --- /dev/null +++ b/shell/platform/android/io/flutter/view/ResourceUpdater.java @@ -0,0 +1,165 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.view; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.widget.Toast; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Date; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; + +public final class ResourceUpdater { + private static final String TAG = "ResourceUpdater"; + + private class DownloadTask extends AsyncTask { + @Override + protected Void doInBackground(String... args) { + try { + URL unresolvedURL = new URL(args[0]); + File localFile = new File(args[1]); + + long startMillis = new Date().getTime(); + Log.i(TAG, "Checking for updates at " + unresolvedURL); + + HttpURLConnection connection = + (HttpURLConnection)unresolvedURL.openConnection(); + + long lastModified = localFile.lastModified(); + if (lastModified != 0) { + Log.i(TAG, "Active update timestamp " + lastModified); + connection.setIfModifiedSince(lastModified); + } + + try (InputStream input = connection.getInputStream()) { + URL resolvedURL = connection.getURL(); + Log.i(TAG, "Resolved update URL " + resolvedURL); + + if (connection.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) { + if (resolvedURL.equals(unresolvedURL)) { + Log.i(TAG, "Rolled back all updates"); + localFile.delete(); + return null; + } else { + Log.i(TAG, "Latest update not found"); + return null; + } + } + + if (connection.getResponseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) { + Log.i(TAG, "Already have latest update"); + return null; + } + + Log.i(TAG, "Downloading update " + unresolvedURL); + try (OutputStream output = new FileOutputStream(localFile)) { + int count; + byte data[] = new byte[1024]; + while ((count = input.read(data)) != -1) { + output.write(data, 0, count); + } + + long totalMillis = new Date().getTime() - startMillis; + Log.i(TAG, "Update downloaded in " + totalMillis / 100 / 10. + "s"); + + output.flush(); + return null; + } + } + + } catch (IOException e) { + Log.w(TAG, "Could not download update " + e.getMessage()); + return null; + } + } + } + + private final Context context; + private DownloadTask downloadTask; + + public ResourceUpdater(Context context) { + this.context = context; + } + + public String getAPKVersion() { + try { + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); + return packageInfo == null ? null : Integer.toString(packageInfo.versionCode); + + } catch (PackageManager.NameNotFoundException e) { + return null; + } + } + + public String getUpdateInstallationPath() { + return context.getFilesDir().toString() + "/update.zip"; + } + + public String buildUpdateDownloadURL() { + Bundle metaData; + try { + metaData = context.getPackageManager().getApplicationInfo( + context.getPackageName(), PackageManager.GET_META_DATA).metaData; + + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + + if (metaData == null || metaData.getString("UpdateServerURL") == null) { + return null; + } + + URI uri; + try { + uri = new URI(metaData.getString("UpdateServerURL") + "/" + getAPKVersion()); + + } catch (URISyntaxException e) { + Log.w(TAG, "Invalid AndroidManifest.xml UpdateServerURL: " + e.getMessage()); + return null; + } + + return uri.normalize().toString(); + } + + public void startUpdateDownloadOnce() { + assert downloadTask == null; + downloadTask = new DownloadTask(); + downloadTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, + buildUpdateDownloadURL(), getUpdateInstallationPath()); + } + + public void waitForDownloadCompletion() { + assert downloadTask != null; + try { + downloadTask.get(); + } catch (CancellationException e) { + Log.w(TAG, "Download cancelled: " + e.getMessage()); + return; + } catch (ExecutionException e) { + Log.w(TAG, "Download exception: " + e.getMessage()); + return; + } catch (InterruptedException e) { + Log.w(TAG, "Download interrupted: " + e.getMessage()); + return; + } + } +}