Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
<string name="idle_reboot_summary_off">Don\'t automatically reboot once the device is idle after successfully installing an update</string>
<string name="check_for_updates_title">Check for updates</string>
<string name="check_for_updates_summary">Tap to check for updates</string>
<string name="install_from_file_title">Install update from file</string>
<string name="install_from_file_summary">Manually choose an OTA update package to verify and install</string>
<string name="install_from_file_confirm_title">Confirm it\'s you</string>
<string name="install_from_file_confirm_description">Authenticate to install an update from a file</string>
<string name="install_from_file_no_credential">Set a device PIN, pattern or password to install updates from a file</string>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GrapheneOS doesn't support pattern

<string name="notification_settings_title">Notification settings</string>
<string name="notification_settings_summary">Modify notification channel settings</string>
<string name="use_security_preview_channel_title">Receive security preview releases</string>
Expand Down
5 changes: 5 additions & 0 deletions res/xml/settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,9 @@

</PreferenceCategory>

<Preference app:key="install_from_file"
app:title="@string/install_from_file_title"
app:summary="@string/install_from_file_summary"
app:iconSpaceReserved="false" />

</PreferenceScreen>
45 changes: 39 additions & 6 deletions src/app/seamlessupdate/client/Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) -> {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand All @@ -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");
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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());
Expand Down
57 changes: 57 additions & 0 deletions src/app/seamlessupdate/client/Settings.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GrapheneOS doesn't support pattern

// 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));
Comment on lines +117 to +120

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyguardManager#createConfirmDeviceCredentialIntent is deprecated

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);
Comment on lines +135 to +137

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subclass chain:

  1. Settings
  2. androidx.fragment.app.FragmentActivity
  3. androidx.activity.ComponentActivity
  4. android.app.Activity

onActivityResult is deprecated in androidx.activity.ComponentActivity. The recommendation is to use Activity Result APIs (ActivityResultLauncher)

(Note that onActivityResult is not deprecated in android.app.Activity or FragmentActivity)

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";
Expand All @@ -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;
});
Comment on lines +180 to +183

@inthewaves inthewaves Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prevent OTA from being picked while update_engine is actively installing, and also e.g. the existing manual network action checks KEY_WAITING_FOR_REBOOT before starting its service


requirePreference(KEY_NETWORK_TYPE).setOnPreferenceChangeListener((pref, newValue) -> {
final int value = Integer.parseInt((String) newValue);
getPreferences(requireContext()).edit().putInt(KEY_NETWORK_TYPE, value).apply();
Expand Down