From 5a212ca1595b980fabaeef5defee656e79a7ac39 Mon Sep 17 00:00:00 2001 From: Joey Date: Mon, 29 Jun 2020 16:47:26 +0200 Subject: [PATCH] Updater: add support for local updates Allow importing and installation of OTA files already downloaded instead of requiring to reboot to recovery to install them. Squash of: - Add support for importing local updates Signed-off-by: Joey Change-Id: I64ca3a6af29bdf8b2c6023a502f23080a27fd79e - OTA: read timestamp from imported zip metadata Signed-off-by: Joey Change-Id: I93a5c0be81adab9ba8e50afde0e09839f059c9e0 - OTA: fix UI issues with local update Signed-off-by: Joey Change-Id: I07c8f5507bc52c254c3dc1468fea495a073ae96c - OTA: fix local updates not being shown in UI (pt.2) Signed-off-by: Joey Change-Id: Ife40eea05099eca9e1ee84c6f87d2715e5981cab - OTA: ignore download status changes for local updates Signed-off-by: Joey Change-Id: I198f9b5462718f8a6e5687c891f3bfc6b1c645bd - UpdaterService: fix crash with local install Change-Id: I27b187cf4adec986d516e3017d1b3877691029b2 Signed-off-by: Alexander Martinz - Local updates: do not remove local update from ui after installation Change-Id: I869e090f26273006f933ad99c42b7c6a2e963797 Signed-off-by: Alexander Martinz - Local updates: modify display version Change-Id: I8a39e0936040bb9546499754ab4a9ef60c56aca0 Signed-off-by: Alexander Martinz - Local updates: show build date in import dialog Change-Id: I9014358ea1cf941e76fdd80a5147e9d924fc1a8f Signed-off-by: Alexander Martinz Change-Id: I64ca3a6af29bdf8b2c6023a502f23080a27fd79e Signed-off-by: Joey Signed-off-by: Alexander Martinz --- .../org/lineageos/updater/UpdateImporter.java | 249 ++++++++++++++++++ .../lineageos/updater/UpdatesActivity.java | 83 +++++- .../lineageos/updater/UpdatesListAdapter.java | 9 + .../updater/controller/ABUpdateInstaller.java | 4 + .../updater/controller/UpdaterController.java | 9 +- .../updater/controller/UpdaterService.java | 10 +- .../org/lineageos/updater/model/Update.java | 1 + app/src/main/res/menu/menu_toolbar.xml | 4 + app/src/main/res/values/strings.xml | 7 + 9 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/lineageos/updater/UpdateImporter.java diff --git a/app/src/main/java/org/lineageos/updater/UpdateImporter.java b/app/src/main/java/org/lineageos/updater/UpdateImporter.java new file mode 100644 index 00000000..791c5244 --- /dev/null +++ b/app/src/main/java/org/lineageos/updater/UpdateImporter.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2017-2022 The LineageOS Project + * Copyright (C) 2020-2022 SHIFT GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lineageos.updater; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import org.json.JSONException; +import org.lineageos.updater.controller.UpdaterController; +import org.lineageos.updater.controller.UpdaterService; +import org.lineageos.updater.misc.StringGenerator; +import org.lineageos.updater.misc.Utils; +import org.lineageos.updater.model.Update; +import org.lineageos.updater.model.UpdateInfo; +import org.lineageos.updater.model.UpdateStatus; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.text.DateFormat; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class UpdateImporter { + private static final int REQUEST_PICK = 9061; + private static final String TAG = "UpdateImporter"; + private static final String MIME_ZIP = "application/zip"; + private static final String FILE_NAME = "localUpdate.zip"; + private static final String METADATA_PATH = "META-INF/com/android/metadata"; + private static final String METADATA_TIMESTAMP_KEY = "post-timestamp="; + + private final Activity activity; + private final Callbacks callbacks; + + private Thread workingThread; + + public UpdateImporter(Activity activity, Callbacks callbacks) { + this.activity = activity; + this.callbacks = callbacks; + } + + public void stopImport() { + if (workingThread != null && workingThread.isAlive()) { + workingThread.interrupt(); + workingThread = null; + } + } + + public void openImportPicker() { + final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(MIME_ZIP); + activity.startActivityForResult(intent, REQUEST_PICK); + } + + public boolean onResult(int requestCode, int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK || requestCode != REQUEST_PICK) { + return false; + } + + return onPicked(data.getData()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private boolean onPicked(Uri uri) { + callbacks.onImportStarted(); + + workingThread = new Thread(() -> { + File importedFile = null; + try { + importedFile = importFile(uri); + verifyPackage(importedFile); + + final Update update = buildLocalUpdate(importedFile); + addUpdate(update); + activity.runOnUiThread(() -> callbacks.onImportCompleted(update)); + } catch (Exception e) { + Log.e(TAG, "Failed to import update package", e); + // Do not store invalid update + if (importedFile != null) { + importedFile.delete(); + } + + activity.runOnUiThread(() -> callbacks.onImportCompleted(null)); + } + }); + workingThread.start(); + return true; + } + + @SuppressLint("SetWorldReadable") + @SuppressWarnings("ResultOfMethodCallIgnored") + private File importFile(Uri uri) throws IOException { + final ParcelFileDescriptor parcelDescriptor = activity.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelDescriptor == null) { + throw new IOException("Failed to obtain fileDescriptor"); + } + + final FileInputStream iStream = new FileInputStream(parcelDescriptor + .getFileDescriptor()); + final File downloadDir = Utils.getDownloadPath(activity); + final File outFile = new File(downloadDir, FILE_NAME); + if (outFile.exists()) { + outFile.delete(); + } + final FileOutputStream oStream = new FileOutputStream(outFile); + + int read; + final byte[] buffer = new byte[4096]; + while ((read = iStream.read(buffer)) > 0) { + oStream.write(buffer, 0, read); + } + oStream.flush(); + oStream.close(); + iStream.close(); + parcelDescriptor.close(); + + outFile.setReadable(true, false); + + return outFile; + } + + private Update buildLocalUpdate(File file) { + final long timeStamp = getTimeStamp(file); + final String buildDate = StringGenerator.getDateLocalizedUTC( + activity, DateFormat.MEDIUM, timeStamp); + final String name = activity.getString(R.string.local_update_name); + final Update update = new Update(); + update.setAvailableOnline(false); + update.setName(name); + update.setFile(file); + update.setFileSize(file.length()); + update.setDownloadId(Update.LOCAL_ID); + update.setTimestamp(timeStamp); + update.setStatus(UpdateStatus.VERIFIED); + update.setPersistentStatus(UpdateStatus.Persistent.VERIFIED); + update.setVersion(String.format("%s (%s)", name, buildDate)); + return update; + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private void verifyPackage(File file) throws Exception { + try { + android.os.RecoverySystem.verifyPackage(file, null, null); + } catch (Exception e) { + if (file.exists()) { + file.delete(); + throw new Exception("Verification failed, file has been deleted"); + } else { + throw e; + } + } + } + + private void addUpdate(Update update) { + UpdaterController controller = UpdaterController.getInstance(activity); + controller.addUpdate(update, false); + } + + private long getTimeStamp(File file) { + try { + final String metadataContent = readZippedFile(file, METADATA_PATH); + final String[] lines = metadataContent.split("\n"); + for (String line : lines) { + if (!line.startsWith(METADATA_TIMESTAMP_KEY)) { + continue; + } + + final String timeStampStr = line.replace(METADATA_TIMESTAMP_KEY, ""); + return Long.parseLong(timeStampStr); + } + } catch (IOException e) { + Log.e(TAG, "Failed to read date from local update zip package", e); + } catch (NumberFormatException e) { + Log.e(TAG, "Failed to parse timestamp number from zip metadata file", e); + } + + Log.e(TAG, "Couldn't find timestamp in zip file, falling back to $now"); + return System.currentTimeMillis(); + } + + private String readZippedFile(File file, String path) throws IOException { + final StringBuilder sb = new StringBuilder(); + InputStream iStream = null; + + try (final ZipFile zip = new ZipFile(file)) { + final Enumeration iterator = zip.entries(); + while (iterator.hasMoreElements()) { + final ZipEntry entry = iterator.nextElement(); + if (!METADATA_PATH.equals(entry.getName())) { + continue; + } + + iStream = zip.getInputStream(entry); + break; + } + + if (iStream == null) { + throw new FileNotFoundException("Couldn't find " + path + " in " + file.getName()); + } + + final byte[] buffer = new byte[1024]; + int read; + while ((read = iStream.read(buffer)) > 0) { + sb.append(new String(buffer, 0, read, StandardCharsets.UTF_8)); + } + } catch (IOException e) { + Log.e(TAG, "Failed to read file from zip package", e); + throw e; + } finally { + if (iStream != null) { + iStream.close(); + } + } + + return sb.toString(); + } + + public interface Callbacks { + void onImportStarted(); + + void onImportCompleted(Update update); + } +} diff --git a/app/src/main/java/org/lineageos/updater/UpdatesActivity.java b/app/src/main/java/org/lineageos/updater/UpdatesActivity.java index c3595753..25ca0b8b 100644 --- a/app/src/main/java/org/lineageos/updater/UpdatesActivity.java +++ b/app/src/main/java/org/lineageos/updater/UpdatesActivity.java @@ -17,6 +17,7 @@ package org.lineageos.updater; import android.annotation.SuppressLint; import android.app.Activity; +import android.app.ProgressDialog; import android.app.UiModeManager; import android.content.BroadcastReceiver; import android.content.ComponentName; @@ -49,6 +50,7 @@ import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SwitchCompat; @@ -72,6 +74,7 @@ import org.lineageos.updater.misc.BuildInfoUtils; import org.lineageos.updater.misc.Constants; import org.lineageos.updater.misc.StringGenerator; import org.lineageos.updater.misc.Utils; +import org.lineageos.updater.model.Update; import org.lineageos.updater.model.UpdateInfo; import java.io.File; @@ -80,7 +83,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; -public class UpdatesActivity extends UpdatesListActivity { +public class UpdatesActivity extends UpdatesListActivity implements UpdateImporter.Callbacks { private static final String TAG = "UpdatesActivity"; private UpdaterService mUpdaterService; @@ -106,11 +109,17 @@ public class UpdatesActivity extends UpdatesListActivity { } }); + private UpdateImporter mUpdateImporter; + @SuppressWarnings("deprecation") + private ProgressDialog importDialog; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_updates); + mUpdateImporter = new UpdateImporter(this, this); + UiModeManager uiModeManager = getSystemService(UiModeManager.class); mIsTV = uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; @@ -234,6 +243,17 @@ public class UpdatesActivity extends UpdatesListActivity { LocalBroadcastManager.getInstance(this).registerReceiver(mBroadcastReceiver, intentFilter); } + @Override + protected void onPause() { + if (importDialog != null) { + importDialog.dismiss(); + importDialog = null; + mUpdateImporter.stopImport(); + } + + super.onPause(); + } + @Override public void onStop() { LocalBroadcastManager.getInstance(this).unregisterReceiver(mBroadcastReceiver); @@ -263,6 +283,9 @@ public class UpdatesActivity extends UpdatesListActivity { Uri.parse(Utils.getChangelogURL(this))); startActivity(openUrl); return true; + } else if (itemId == R.id.menu_local_update) { + mUpdateImporter.openImportPicker(); + return true; } return super.onOptionsItemSelected(item); } @@ -273,8 +296,60 @@ public class UpdatesActivity extends UpdatesListActivity { return true; } - private final ServiceConnection mConnection = new ServiceConnection() { + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (!mUpdateImporter.onResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data); + } + } + @Override + @SuppressWarnings("deprecation") + public void onImportStarted() { + if (importDialog != null && importDialog.isShowing()) { + importDialog.dismiss(); + } + + importDialog = ProgressDialog.show(this, getString(R.string.local_update_import), + getString(R.string.local_update_import_progress), true, false); + } + + @Override + public void onImportCompleted(Update update) { + if (importDialog != null) { + importDialog.dismiss(); + importDialog = null; + } + + if (update == null) { + new AlertDialog.Builder(this) + .setTitle(R.string.local_update_import) + .setMessage(R.string.local_update_import_failure) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; + } + + mAdapter.notifyDataSetChanged(); + + final Runnable deleteUpdate = () -> UpdaterController.getInstance(this) + .deleteUpdate(update.getDownloadId()); + + new AlertDialog.Builder(this) + .setTitle(R.string.local_update_import) + .setMessage(getString(R.string.local_update_import_success, update.getVersion())) + .setPositiveButton(R.string.local_update_import_install, (dialog, which) -> { + mAdapter.addItem(update.getDownloadId()); + // Update UI + getUpdatesList(); + Utils.triggerUpdate(this, update.getDownloadId()); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> deleteUpdate.run()) + .setOnCancelListener((dialog) -> deleteUpdate.run()) + .show(); + } + + private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName className, IBinder service) { @@ -425,6 +500,10 @@ public class UpdatesActivity extends UpdatesListActivity { } private void handleDownloadStatusChange(String downloadId) { + if (Update.LOCAL_ID.equals(downloadId)) { + return; + } + UpdateInfo update = mUpdaterService.getUpdaterController().getUpdate(downloadId); switch (update.getStatus()) { case PAUSED_ERROR: diff --git a/app/src/main/java/org/lineageos/updater/UpdatesListAdapter.java b/app/src/main/java/org/lineageos/updater/UpdatesListAdapter.java index 09564493..2a62b630 100644 --- a/app/src/main/java/org/lineageos/updater/UpdatesListAdapter.java +++ b/app/src/main/java/org/lineageos/updater/UpdatesListAdapter.java @@ -65,6 +65,7 @@ import org.lineageos.updater.model.UpdateStatus; import java.io.IOException; import java.text.DateFormat; import java.text.NumberFormat; +import java.util.ArrayList; import java.util.List; public class UpdatesListAdapter extends RecyclerView.Adapter { @@ -297,6 +298,14 @@ public class UpdatesListAdapter extends RecyclerView.Adapter(); + } + mDownloadIds.add(0, downloadId); + notifyItemInserted(0); + } + public void notifyItemChanged(String downloadId) { if (mDownloadIds == null) { return; diff --git a/app/src/main/java/org/lineageos/updater/controller/ABUpdateInstaller.java b/app/src/main/java/org/lineageos/updater/controller/ABUpdateInstaller.java index 9fdc75e0..a7ad9fa7 100644 --- a/app/src/main/java/org/lineageos/updater/controller/ABUpdateInstaller.java +++ b/app/src/main/java/org/lineageos/updater/controller/ABUpdateInstaller.java @@ -159,6 +159,10 @@ class ABUpdateInstaller { mDownloadId = downloadId; File file = mUpdaterController.getActualUpdate(mDownloadId).getFile(); + install(file, downloadId); + } + + public void install(File file, String downloadId) { if (!file.exists()) { Log.e(TAG, "The given update doesn't exist"); mUpdaterController.getActualUpdate(downloadId) diff --git a/app/src/main/java/org/lineageos/updater/controller/UpdaterController.java b/app/src/main/java/org/lineageos/updater/controller/UpdaterController.java index 5d7d51a7..daa710cb 100644 --- a/app/src/main/java/org/lineageos/updater/controller/UpdaterController.java +++ b/app/src/main/java/org/lineageos/updater/controller/UpdaterController.java @@ -66,7 +66,7 @@ public class UpdaterController { private int mActiveDownloads = 0; private final Set mVerifyingUpdates = new HashSet<>(); - protected static synchronized UpdaterController getInstance(Context context) { + public static synchronized UpdaterController getInstance(Context context) { if (sUpdaterController == null) { sUpdaterController = new UpdaterController(context); } @@ -330,7 +330,7 @@ public class UpdaterController { return addUpdate(update, true); } - private boolean addUpdate(final UpdateInfo updateInfo, boolean availableOnline) { + public boolean addUpdate(final UpdateInfo updateInfo, boolean availableOnline) { Log.d(TAG, "Adding download: " + updateInfo.getDownloadId()); if (mDownloads.containsKey(updateInfo.getDownloadId())) { Log.d(TAG, "Download (" + updateInfo.getDownloadId() + ") already added"); @@ -470,7 +470,7 @@ public class UpdaterController { } public void deleteUpdate(String downloadId) { - Log.d(TAG, "Cancelling " + downloadId); + Log.d(TAG, "Deleting update: " + downloadId); if (!mDownloads.containsKey(downloadId) || isDownloading(downloadId)) { return; } @@ -482,7 +482,8 @@ public class UpdaterController { update.setPersistentStatus(UpdateStatus.Persistent.UNKNOWN); deleteUpdateAsync(update); - if (!update.getAvailableOnline()) { + final boolean isLocalUpdate = Update.LOCAL_ID.equals(downloadId); + if (!isLocalUpdate && !update.getAvailableOnline()) { Log.d(TAG, "Download no longer available online, removing"); mDownloads.remove(downloadId); notifyUpdateDelete(downloadId); diff --git a/app/src/main/java/org/lineageos/updater/controller/UpdaterService.java b/app/src/main/java/org/lineageos/updater/controller/UpdaterService.java index cd9334ba..c279679a 100644 --- a/app/src/main/java/org/lineageos/updater/controller/UpdaterService.java +++ b/app/src/main/java/org/lineageos/updater/controller/UpdaterService.java @@ -41,9 +41,11 @@ import org.lineageos.updater.misc.BuildInfoUtils; import org.lineageos.updater.misc.Constants; import org.lineageos.updater.misc.StringGenerator; import org.lineageos.updater.misc.Utils; +import org.lineageos.updater.model.Update; import org.lineageos.updater.model.UpdateInfo; import org.lineageos.updater.model.UpdateStatus; +import java.io.File; import java.io.IOException; import java.text.DateFormat; import java.text.NumberFormat; @@ -122,8 +124,10 @@ public class UpdaterService extends Service { setNotificationTitle(update); handleInstallProgress(update); } else if (UpdaterController.ACTION_UPDATE_REMOVED.equals(intent.getAction())) { + final boolean isLocalUpdate = Update.LOCAL_ID.equals(downloadId); Bundle extras = mNotificationBuilder.getExtras(); - if (downloadId.equals(extras.getString(UpdaterController.EXTRA_DOWNLOAD_ID))) { + if (extras != null && !isLocalUpdate && downloadId.equals( + extras.getString(UpdaterController.EXTRA_DOWNLOAD_ID))) { mNotificationBuilder.setExtras(null); UpdateInfo update = mUpdaterController.getUpdate(downloadId); if (update.getStatus() != UpdateStatus.INSTALLED) { @@ -408,7 +412,9 @@ public class UpdaterService extends Service { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this); boolean deleteUpdate = pref.getBoolean(Constants.PREF_AUTO_DELETE_UPDATES, false); - if (deleteUpdate) { + boolean isLocal = Update.LOCAL_ID.equals(update.getDownloadId()); + // Always delete local updates + if (deleteUpdate || isLocal) { mUpdaterController.deleteUpdate(update.getDownloadId()); } diff --git a/app/src/main/java/org/lineageos/updater/model/Update.java b/app/src/main/java/org/lineageos/updater/model/Update.java index ff778495..d3cb6b26 100644 --- a/app/src/main/java/org/lineageos/updater/model/Update.java +++ b/app/src/main/java/org/lineageos/updater/model/Update.java @@ -18,6 +18,7 @@ package org.lineageos.updater.model; import java.io.File; public class Update extends UpdateBase implements UpdateInfo { + public static final String LOCAL_ID = "local"; private UpdateStatus mStatus = UpdateStatus.UNKNOWN; private int mPersistentStatus = UpdateStatus.Persistent.UNKNOWN; diff --git a/app/src/main/res/menu/menu_toolbar.xml b/app/src/main/res/menu/menu_toolbar.xml index 8b9117ce..dbca3d03 100644 --- a/app/src/main/res/menu/menu_toolbar.xml +++ b/app/src/main/res/menu/menu_toolbar.xml @@ -6,6 +6,10 @@ android:icon="@drawable/ic_menu_refresh" android:title="@string/menu_refresh" app:showAsAction="ifRoom" /> + Did you know? LineageOS updates are full installation packages. That means you can always install only the latest update, even if you skipped some in between! Thanks for the info! + + Local update + Importing local update\u2026 + %1$s has been imported. Do you want to install it? + Failed to import local update + Install + Local update