From 48c5410fa2edbd5890b5cf8cdc100bacf885cbf0 Mon Sep 17 00:00:00 2001 From: flawedworld Date: Tue, 23 Jun 2026 23:43:25 +0000 Subject: [PATCH] Implement support for out of band OTAs via file picker --- res/values/strings.xml | 5 ++ res/xml/settings.xml | 5 ++ src/app/seamlessupdate/client/Service.java | 45 +++++++++++++--- src/app/seamlessupdate/client/Settings.java | 57 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) 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();