diff --git a/res/values/strings.xml b/res/values/strings.xml
index c005ade..3775379 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -48,6 +48,11 @@
Don\'t automatically reboot once the device is idle after successfully installing an update
Check for updates
Tap to check for updates
+ Install update from file
+ Manually choose an OTA update package to verify and install
+ Confirm it\'s you
+ Authenticate to install an update from a file
+ Set a device PIN, pattern or password to install updates from a file
Notification settings
Modify notification channel settings
Receive security preview releases
diff --git a/res/xml/settings.xml b/res/xml/settings.xml
index 43517af..9a50a73 100644
--- a/res/xml/settings.xml
+++ b/res/xml/settings.xml
@@ -66,4 +66,9 @@
+
+
diff --git a/src/app/seamlessupdate/client/Service.java b/src/app/seamlessupdate/client/Service.java
index c54c036..abf5572 100644
--- a/src/app/seamlessupdate/client/Service.java
+++ b/src/app/seamlessupdate/client/Service.java
@@ -8,6 +8,7 @@
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Network;
+import android.net.Uri;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.os.RecoverySystem;
@@ -46,6 +47,7 @@ public class Service extends IntentService {
private static final String TAG = "Service";
static final String INTENT_EXTRA_NETWORK = "network";
static final String INTENT_EXTRA_IS_USER_INITIATED = "is_user_initiated";
+ static final String INTENT_EXTRA_LOCAL_URI = "local_uri";
private static final int CONNECT_TIMEOUT = 30000;
private static final int READ_TIMEOUT = 30000;
private static final File CARE_MAP_PATH = new File("/data/ota_package/care_map.pb");
@@ -148,8 +150,9 @@ private static ZipEntry getEntry(final ZipFile zipFile, final String name) throw
return entry;
}
- private void onDownloadFinished(final boolean streaming, final long targetBuildDate,
- final String targetIncremental) throws IOException, GeneralSecurityException {
+ private void onDownloadFinished(final boolean streaming, final boolean local,
+ final long targetBuildDate, final String targetIncremental)
+ throws IOException, GeneralSecurityException {
try {
notificationHandler.showVerifyNotification(0);
RecoverySystem.verifyPackage(UPDATE_PATH, (int progress) -> {
@@ -193,10 +196,12 @@ private void onDownloadFinished(final boolean streaming, final long targetBuildD
}
}
}
- if (timestamp != targetBuildDate) {
+ // A locally provided OTA via file picker has no server metadata to cross-check
+ // against, so skip the timestamp and incremental comparisons for it.
+ if (!local && timestamp != targetBuildDate) {
throw new GeneralSecurityException("timestamp does not match server metadata");
}
- if (!targetIncremental.equals(incremental)) {
+ if (!local && !targetIncremental.equals(incremental)) {
throw new GeneralSecurityException("incremental does not match server metadata");
}
if (!DEVICE.equals(device)) {
@@ -258,6 +263,27 @@ private void annoyUser() {
notificationHandler.showRebootNotification();
}
+ // Copy a local OTA file given via the file picker into UPDATE_PATH and
+ // install it. The package goes through the same RecoverySystem signature verification and
+ // metadata checks (device, A/B type, source build) as a downloaded update in
+ // onDownloadFinished(); only the server timestamp and incremental cross-checks are skipped
+ // since there is no server metadata.
+ private void installLocalUpdate(final SharedPreferences preferences, final Uri localUri)
+ throws IOException, GeneralSecurityException {
+ notificationHandler.showDownloadNotification(0, 0);
+ Files.deleteIfExists(UPDATE_PATH.toPath());
+ // Clear stale download bookkeeping so a later network check doesn't try to resume the copy.
+ preferences.edit().remove(PREFERENCE_DOWNLOAD_FILE).commit();
+ try (final InputStream input = getContentResolver().openInputStream(localUri)) {
+ if (input == null) {
+ throw new IOException("unable to open " + localUri);
+ }
+ Files.copy(input, UPDATE_PATH.toPath());
+ }
+ Log.d(TAG, "local OTA copy completed");
+ onDownloadFinished(false, true, 0, null);
+ }
+
@Override
protected void onHandleIntent(final Intent intent) {
Log.d(TAG, "onHandleIntent");
@@ -266,6 +292,8 @@ protected void onHandleIntent(final Intent intent) {
final var serviceIsUserInitiated = intent.getBooleanExtra(INTENT_EXTRA_IS_USER_INITIATED, false);
if (serviceIsUserInitiated) Log.d(TAG, "onHandleIntent() – service is user-initiated");
+ final Uri localUri = intent.getParcelableExtra(INTENT_EXTRA_LOCAL_URI, Uri.class);
+
final PowerManager pm = getSystemService(PowerManager.class);
final WakeLock wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Updater:" + TAG);
HttpsURLConnection connection = null;
@@ -285,6 +313,11 @@ protected void onHandleIntent(final Intent intent) {
mUpdating = true;
notificationHandler.start();
+ if (localUri != null) {
+ installLocalUpdate(preferences, localUri);
+ return;
+ }
+
if (network == null) {
throw new IOException("Network is unavailable");
}
@@ -341,7 +374,7 @@ protected void onHandleIntent(final Intent intent) {
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_RANGE_NOT_SATISFIABLE) {
Log.d(TAG, "download completed previously");
- onDownloadFinished(streaming, targetBuildDate, targetIncremental);
+ onDownloadFinished(streaming, false, targetBuildDate, targetIncremental);
return;
}
if (responseCode == HTTP_NOT_FOUND && incrementalUpdate.equals(downloadFile)) {
@@ -418,7 +451,7 @@ protected void onHandleIntent(final Intent intent) {
}
Log.d(TAG, "download completed");
- onDownloadFinished(streaming, targetBuildDate, targetIncremental);
+ onDownloadFinished(streaming, false, targetBuildDate, targetIncremental);
} catch (GeneralSecurityException | IOException | ServiceSpecificException e) {
Log.e(TAG, "failed to download and install update", e);
notificationHandler.showFailureNotification(e.getMessage());
diff --git a/src/app/seamlessupdate/client/Settings.java b/src/app/seamlessupdate/client/Settings.java
index aa456ba..36ae85a 100644
--- a/src/app/seamlessupdate/client/Settings.java
+++ b/src/app/seamlessupdate/client/Settings.java
@@ -1,14 +1,18 @@
package app.seamlessupdate.client;
+import android.app.Activity;
+import android.app.KeyguardManager;
import android.net.Network;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
+import android.net.Uri;
import android.os.Bundle;
import android.os.UserManager;
import android.util.Log;
import android.view.MenuItem;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -31,6 +35,11 @@ public class Settings extends CollapsingToolbarBaseActivity {
private static final String KEY_IDLE_REBOOT = "idle_reboot";
private static final String KEY_CHECK_FOR_UPDATES = "check_for_updates";
static final String KEY_WAITING_FOR_REBOOT = "waiting_for_reboot";
+ static final String KEY_INSTALL_FROM_FILE = "install_from_file";
+
+ private static final int REQUEST_CONFIRM_CREDENTIAL = 1;
+ private static final int REQUEST_PICK_OTA = 2;
+ private static final String OTA_MIME_TYPE = "application/zip";
static SharedPreferences getPreferences(final Context context) {
final Context deviceContext = context.createDeviceProtectedStorageContext();
@@ -100,6 +109,49 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
return super.onOptionsItemSelected(item);
}
+ // Manually install an OTA from a file chosen with the system file picker. This is gated behind
+ // a confirmation of the owner's device credential (PIN / password / pattern) so it cannot be
+ // triggered without the owner's authorization. The chosen package is still fully signature- and
+ // metadata-verified by the Service before installation.
+ void startInstallFromFile() {
+ // Returns null when no device credential is set, which is the only state we need to reject.
+ final Intent intent = getSystemService(KeyguardManager.class).createConfirmDeviceCredentialIntent(
+ getString(R.string.install_from_file_confirm_title),
+ getString(R.string.install_from_file_confirm_description));
+ if (intent == null) {
+ Toast.makeText(this, R.string.install_from_file_no_credential, Toast.LENGTH_LONG).show();
+ return;
+ }
+ startActivityForResult(intent, REQUEST_CONFIRM_CREDENTIAL);
+ }
+
+ private void pickOtaFile() {
+ final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(OTA_MIME_TYPE);
+ startActivityForResult(intent, REQUEST_PICK_OTA);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (resultCode != Activity.RESULT_OK) {
+ return;
+ }
+ if (requestCode == REQUEST_CONFIRM_CREDENTIAL) {
+ pickOtaFile();
+ } else if (requestCode == REQUEST_PICK_OTA && data != null) {
+ final Uri uri = data.getData();
+ if (uri == null) {
+ return;
+ }
+ final Intent intent = new Intent(this, Service.class);
+ intent.putExtra(Service.INTENT_EXTRA_IS_USER_INITIATED, true);
+ intent.putExtra(Service.INTENT_EXTRA_LOCAL_URI, uri);
+ startForegroundService(intent);
+ }
+ }
+
public static class SettingsFragment extends PreferenceFragment
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static String TAG = "SettingsFragment";
@@ -125,6 +177,11 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
return true;
});
+ requirePreference(KEY_INSTALL_FROM_FILE).setOnPreferenceClickListener(pref -> {
+ ((Settings) requireActivity()).startInstallFromFile();
+ return true;
+ });
+
requirePreference(KEY_NETWORK_TYPE).setOnPreferenceChangeListener((pref, newValue) -> {
final int value = Integer.parseInt((String) newValue);
getPreferences(requireContext()).edit().putInt(KEY_NETWORK_TYPE, value).apply();