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 <jbevilacqua@shiftphones.com>
  Change-Id: I64ca3a6af29bdf8b2c6023a502f23080a27fd79e
- OTA: read timestamp from imported zip metadata
  Signed-off-by: Joey <jbevilacqua@shiftphones.com>
  Change-Id: I93a5c0be81adab9ba8e50afde0e09839f059c9e0
- OTA: fix UI issues with local update
  Signed-off-by: Joey <jbevilacqua@shiftphones.com>
  Change-Id: I07c8f5507bc52c254c3dc1468fea495a073ae96c
- OTA: fix local updates not being shown in UI (pt.2)
  Signed-off-by: Joey <jbevilacqua@shiftphones.com>
  Change-Id: Ife40eea05099eca9e1ee84c6f87d2715e5981cab
- OTA: ignore download status changes for local updates
  Signed-off-by: Joey <jbevilacqua@shiftphones.com>
  Change-Id: I198f9b5462718f8a6e5687c891f3bfc6b1c645bd
- UpdaterService: fix crash with local install
  Change-Id: I27b187cf4adec986d516e3017d1b3877691029b2
  Signed-off-by: Alexander Martinz <amartinz@shiftphones.com>
- Local updates: do not remove local update from ui after installation
  Change-Id: I869e090f26273006f933ad99c42b7c6a2e963797
  Signed-off-by: Alexander Martinz <amartinz@shiftphones.com>
- Local updates: modify display version
  Change-Id: I8a39e0936040bb9546499754ab4a9ef60c56aca0
  Signed-off-by: Alexander Martinz <amartinz@shiftphones.com>
- Local updates: show build date in import dialog
  Change-Id: I9014358ea1cf941e76fdd80a5147e9d924fc1a8f
  Signed-off-by: Alexander Martinz <amartinz@shiftphones.com>

Change-Id: I64ca3a6af29bdf8b2c6023a502f23080a27fd79e
Signed-off-by: Joey <joey@lineageos.org>
Signed-off-by: Alexander Martinz <amartinz@shiftphones.com>
This commit is contained in:
Joey
2020-06-29 16:47:26 +02:00
committed by danielml
parent 5ff552fa43
commit 5a212ca159
9 changed files with 368 additions and 8 deletions

View File

@@ -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<? extends ZipEntry> 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);
}
}

View File

@@ -17,6 +17,7 @@ package org.lineageos.updater;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.ProgressDialog;
import android.app.UiModeManager; import android.app.UiModeManager;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.ComponentName; import android.content.ComponentName;
@@ -49,6 +50,7 @@ import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat; 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.Constants;
import org.lineageos.updater.misc.StringGenerator; import org.lineageos.updater.misc.StringGenerator;
import org.lineageos.updater.misc.Utils; import org.lineageos.updater.misc.Utils;
import org.lineageos.updater.model.Update;
import org.lineageos.updater.model.UpdateInfo; import org.lineageos.updater.model.UpdateInfo;
import java.io.File; import java.io.File;
@@ -80,7 +83,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public class UpdatesActivity extends UpdatesListActivity { public class UpdatesActivity extends UpdatesListActivity implements UpdateImporter.Callbacks {
private static final String TAG = "UpdatesActivity"; private static final String TAG = "UpdatesActivity";
private UpdaterService mUpdaterService; private UpdaterService mUpdaterService;
@@ -106,11 +109,17 @@ public class UpdatesActivity extends UpdatesListActivity {
} }
}); });
private UpdateImporter mUpdateImporter;
@SuppressWarnings("deprecation")
private ProgressDialog importDialog;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_updates); setContentView(R.layout.activity_updates);
mUpdateImporter = new UpdateImporter(this, this);
UiModeManager uiModeManager = getSystemService(UiModeManager.class); UiModeManager uiModeManager = getSystemService(UiModeManager.class);
mIsTV = uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; mIsTV = uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
@@ -234,6 +243,17 @@ public class UpdatesActivity extends UpdatesListActivity {
LocalBroadcastManager.getInstance(this).registerReceiver(mBroadcastReceiver, intentFilter); LocalBroadcastManager.getInstance(this).registerReceiver(mBroadcastReceiver, intentFilter);
} }
@Override
protected void onPause() {
if (importDialog != null) {
importDialog.dismiss();
importDialog = null;
mUpdateImporter.stopImport();
}
super.onPause();
}
@Override @Override
public void onStop() { public void onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(mBroadcastReceiver); LocalBroadcastManager.getInstance(this).unregisterReceiver(mBroadcastReceiver);
@@ -263,6 +283,9 @@ public class UpdatesActivity extends UpdatesListActivity {
Uri.parse(Utils.getChangelogURL(this))); Uri.parse(Utils.getChangelogURL(this)));
startActivity(openUrl); startActivity(openUrl);
return true; return true;
} else if (itemId == R.id.menu_local_update) {
mUpdateImporter.openImportPicker();
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
@@ -273,8 +296,60 @@ public class UpdatesActivity extends UpdatesListActivity {
return true; 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 @Override
public void onServiceConnected(ComponentName className, public void onServiceConnected(ComponentName className,
IBinder service) { IBinder service) {
@@ -425,6 +500,10 @@ public class UpdatesActivity extends UpdatesListActivity {
} }
private void handleDownloadStatusChange(String downloadId) { private void handleDownloadStatusChange(String downloadId) {
if (Update.LOCAL_ID.equals(downloadId)) {
return;
}
UpdateInfo update = mUpdaterService.getUpdaterController().getUpdate(downloadId); UpdateInfo update = mUpdaterService.getUpdaterController().getUpdate(downloadId);
switch (update.getStatus()) { switch (update.getStatus()) {
case PAUSED_ERROR: case PAUSED_ERROR:

View File

@@ -65,6 +65,7 @@ import org.lineageos.updater.model.UpdateStatus;
import java.io.IOException; import java.io.IOException;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List; import java.util.List;
public class UpdatesListAdapter extends RecyclerView.Adapter<UpdatesListAdapter.ViewHolder> { public class UpdatesListAdapter extends RecyclerView.Adapter<UpdatesListAdapter.ViewHolder> {
@@ -297,6 +298,14 @@ public class UpdatesListAdapter extends RecyclerView.Adapter<UpdatesListAdapter.
mDownloadIds = downloadIds; mDownloadIds = downloadIds;
} }
public void addItem(String downloadId) {
if (mDownloadIds == null) {
mDownloadIds = new ArrayList<>();
}
mDownloadIds.add(0, downloadId);
notifyItemInserted(0);
}
public void notifyItemChanged(String downloadId) { public void notifyItemChanged(String downloadId) {
if (mDownloadIds == null) { if (mDownloadIds == null) {
return; return;

View File

@@ -159,6 +159,10 @@ class ABUpdateInstaller {
mDownloadId = downloadId; mDownloadId = downloadId;
File file = mUpdaterController.getActualUpdate(mDownloadId).getFile(); File file = mUpdaterController.getActualUpdate(mDownloadId).getFile();
install(file, downloadId);
}
public void install(File file, String downloadId) {
if (!file.exists()) { if (!file.exists()) {
Log.e(TAG, "The given update doesn't exist"); Log.e(TAG, "The given update doesn't exist");
mUpdaterController.getActualUpdate(downloadId) mUpdaterController.getActualUpdate(downloadId)

View File

@@ -66,7 +66,7 @@ public class UpdaterController {
private int mActiveDownloads = 0; private int mActiveDownloads = 0;
private final Set<String> mVerifyingUpdates = new HashSet<>(); private final Set<String> mVerifyingUpdates = new HashSet<>();
protected static synchronized UpdaterController getInstance(Context context) { public static synchronized UpdaterController getInstance(Context context) {
if (sUpdaterController == null) { if (sUpdaterController == null) {
sUpdaterController = new UpdaterController(context); sUpdaterController = new UpdaterController(context);
} }
@@ -330,7 +330,7 @@ public class UpdaterController {
return addUpdate(update, true); 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()); Log.d(TAG, "Adding download: " + updateInfo.getDownloadId());
if (mDownloads.containsKey(updateInfo.getDownloadId())) { if (mDownloads.containsKey(updateInfo.getDownloadId())) {
Log.d(TAG, "Download (" + updateInfo.getDownloadId() + ") already added"); Log.d(TAG, "Download (" + updateInfo.getDownloadId() + ") already added");
@@ -470,7 +470,7 @@ public class UpdaterController {
} }
public void deleteUpdate(String downloadId) { public void deleteUpdate(String downloadId) {
Log.d(TAG, "Cancelling " + downloadId); Log.d(TAG, "Deleting update: " + downloadId);
if (!mDownloads.containsKey(downloadId) || isDownloading(downloadId)) { if (!mDownloads.containsKey(downloadId) || isDownloading(downloadId)) {
return; return;
} }
@@ -482,7 +482,8 @@ public class UpdaterController {
update.setPersistentStatus(UpdateStatus.Persistent.UNKNOWN); update.setPersistentStatus(UpdateStatus.Persistent.UNKNOWN);
deleteUpdateAsync(update); 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"); Log.d(TAG, "Download no longer available online, removing");
mDownloads.remove(downloadId); mDownloads.remove(downloadId);
notifyUpdateDelete(downloadId); notifyUpdateDelete(downloadId);

View File

@@ -41,9 +41,11 @@ import org.lineageos.updater.misc.BuildInfoUtils;
import org.lineageos.updater.misc.Constants; import org.lineageos.updater.misc.Constants;
import org.lineageos.updater.misc.StringGenerator; import org.lineageos.updater.misc.StringGenerator;
import org.lineageos.updater.misc.Utils; import org.lineageos.updater.misc.Utils;
import org.lineageos.updater.model.Update;
import org.lineageos.updater.model.UpdateInfo; import org.lineageos.updater.model.UpdateInfo;
import org.lineageos.updater.model.UpdateStatus; import org.lineageos.updater.model.UpdateStatus;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.NumberFormat; import java.text.NumberFormat;
@@ -122,8 +124,10 @@ public class UpdaterService extends Service {
setNotificationTitle(update); setNotificationTitle(update);
handleInstallProgress(update); handleInstallProgress(update);
} else if (UpdaterController.ACTION_UPDATE_REMOVED.equals(intent.getAction())) { } else if (UpdaterController.ACTION_UPDATE_REMOVED.equals(intent.getAction())) {
final boolean isLocalUpdate = Update.LOCAL_ID.equals(downloadId);
Bundle extras = mNotificationBuilder.getExtras(); 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); mNotificationBuilder.setExtras(null);
UpdateInfo update = mUpdaterController.getUpdate(downloadId); UpdateInfo update = mUpdaterController.getUpdate(downloadId);
if (update.getStatus() != UpdateStatus.INSTALLED) { if (update.getStatus() != UpdateStatus.INSTALLED) {
@@ -408,7 +412,9 @@ public class UpdaterService extends Service {
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this);
boolean deleteUpdate = pref.getBoolean(Constants.PREF_AUTO_DELETE_UPDATES, false); 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()); mUpdaterController.deleteUpdate(update.getDownloadId());
} }

View File

@@ -18,6 +18,7 @@ package org.lineageos.updater.model;
import java.io.File; import java.io.File;
public class Update extends UpdateBase implements UpdateInfo { public class Update extends UpdateBase implements UpdateInfo {
public static final String LOCAL_ID = "local";
private UpdateStatus mStatus = UpdateStatus.UNKNOWN; private UpdateStatus mStatus = UpdateStatus.UNKNOWN;
private int mPersistentStatus = UpdateStatus.Persistent.UNKNOWN; private int mPersistentStatus = UpdateStatus.Persistent.UNKNOWN;

View File

@@ -6,6 +6,10 @@
android:icon="@drawable/ic_menu_refresh" android:icon="@drawable/ic_menu_refresh"
android:title="@string/menu_refresh" android:title="@string/menu_refresh"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_local_update"
android:title="@string/local_update_import"
app:showAsAction="never" />
<item <item
android:id="@+id/menu_preferences" android:id="@+id/menu_preferences"
android:title="@string/menu_preferences" android:title="@string/menu_preferences"

View File

@@ -157,4 +157,11 @@
<string name="info_dialog_title">Did you know?</string> <string name="info_dialog_title">Did you know?</string>
<string name="info_dialog_message">LineageOS updates are full installation packages. That means you can always install only the latest update, even if you skipped some in between!</string> <string name="info_dialog_message">LineageOS updates are full installation packages. That means you can always install only the latest update, even if you skipped some in between!</string>
<string name="info_dialog_ok">Thanks for the info!</string> <string name="info_dialog_ok">Thanks for the info!</string>
<string name="local_update_import">Local update</string>
<string name="local_update_import_progress">Importing local update\u2026</string>
<string name="local_update_import_success">%1$s has been imported. Do you want to install it?</string>
<string name="local_update_import_failure">Failed to import local update</string>
<string name="local_update_import_install">Install</string>
<string name="local_update_name">Local update</string>
</resources> </resources>