mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Downloading and installation of dynamic updates on Android (#7207)
This commit is contained in:
parent
8e56b549af
commit
18a4e33c2a
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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<String> bundlePaths = new ArrayList<String>();
|
||||
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<String> bundlePaths = new ArrayList<String>();
|
||||
if (FlutterMain.getUpdateInstallationPath() != null) {
|
||||
bundlePaths.add(FlutterMain.getUpdateInstallationPath());
|
||||
}
|
||||
bundlePaths.add(appBundlePath);
|
||||
args.bundlePaths = bundlePaths.toArray(new String[0]);
|
||||
args.entrypoint = "main";
|
||||
flutterView.runFromBundle(args);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
165
shell/platform/android/io/flutter/view/ResourceUpdater.java
Normal file
165
shell/platform/android/io/flutter/view/ResourceUpdater.java
Normal file
@ -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<String, String, Void> {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user