fdroid-client/app/src/main/java/org/fdroid/fdroid/UpdateService.java

589 lines
26 KiB
Java

/*
* Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.fdroid.fdroid;
import android.app.NotificationManager;
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Build;
import android.os.Process;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import org.fdroid.CompatibilityChecker;
import org.fdroid.CompatibilityCheckerImpl;
import org.fdroid.database.DbUpdateChecker;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.Repository;
import org.fdroid.database.UpdatableApp;
import org.fdroid.download.Mirror;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.net.BluetoothDownloader;
import org.fdroid.fdroid.net.ConnectivityMonitorService;
import org.fdroid.fdroid.net.DownloaderFactory;
import org.fdroid.index.IndexUpdateResult;
import org.fdroid.index.RepoUpdater;
import org.fdroid.index.RepoUriBuilder;
import org.fdroid.index.TempFileProvider;
import org.fdroid.index.v1.IndexV1Updater;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.JobIntentService;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class UpdateService extends JobIntentService {
private static final String TAG = "UpdateService";
public static final String LOCAL_ACTION_STATUS = "status";
public static final String EXTRA_MESSAGE = "msg";
public static final String EXTRA_REPO_ID = "repoId";
public static final String EXTRA_REPO_FINGERPRINT = "fingerprint";
public static final String EXTRA_REPO_ERRORS = "repoErrors";
public static final String EXTRA_STATUS_CODE = "status";
public static final String EXTRA_MANUAL_UPDATE = "manualUpdate";
public static final String EXTRA_FORCED_UPDATE = "forcedUpdate";
public static final String EXTRA_PROGRESS = "progress";
public static final int STATUS_COMPLETE_WITH_CHANGES = 0;
public static final int STATUS_COMPLETE_AND_SAME = 1;
public static final int STATUS_ERROR_GLOBAL = 2;
public static final int STATUS_ERROR_LOCAL = 3;
public static final int STATUS_ERROR_LOCAL_SMALL = 4;
public static final int STATUS_INFO = 5;
/**
* This number should never change, it is used by ROMs to trigger
* the first background update of F-Droid during setup.
*
* @see <a href="https://gitlab.com/fdroid/fdroidclient/-/issues/2147">Add a way to trigger an index update externally</a>
* @see <a href="https://review.calyxos.org/c/CalyxOS/platform_packages_apps_SetupWizard/+/3461"/>Schedule F-Droid index update on initialization and network connection</a>
*/
private static final int JOB_ID = 0xfedcba;
private static final int NOTIFY_ID_UPDATING = 0;
private static UpdateService updateService;
private FDroidDatabase db;
private NotificationManager notificationManager;
private NotificationCompat.Builder notificationBuilder;
private AppUpdateStatusManager appUpdateStatusManager;
public static void updateNow(Context context) {
updateRepoNow(context, null);
}
public static void updateRepoNow(Context context, String address) {
updateNewRepoNow(context, address, null);
}
public static Intent getIntent(Context context, String address, @Nullable String fingerprint) {
Intent intent = new Intent(context, UpdateService.class);
intent.putExtra(EXTRA_MANUAL_UPDATE, true);
intent.putExtra(EXTRA_REPO_FINGERPRINT, fingerprint);
if (!TextUtils.isEmpty(address)) {
intent.setData(Uri.parse(address));
}
return intent;
}
public static void updateNewRepoNow(Context context, String address, @Nullable String fingerprint) {
enqueueWork(context, getIntent(context, address, fingerprint));
}
/**
* For when an automatic process needs to force an index update, like
* when the system language changes, or the underlying OS was upgraded.
* This wipes the existing database before running the update!
*/
public static void forceUpdateRepo(Context context) {
Intent intent = new Intent(context, UpdateService.class);
intent.putExtra(EXTRA_FORCED_UPDATE, true);
enqueueWork(context, intent);
}
/**
* Add work to the queue for processing now.
* <p>
* This also shows a {@link Toast} if the Data/WiFi Settings make it so the
* update process is not allowed to run and the device is attached to a
* network (e.g. is not offline or in Airplane Mode).
*
* @see JobIntentService#enqueueWork(Context, Class, int, Intent)
*/
private static void enqueueWork(Context context, @NonNull Intent intent) {
if (FDroidApp.networkState > 0 && !Preferences.get().isOnDemandDownloadAllowed()) {
Toast.makeText(context, R.string.updates_disabled_by_settings, Toast.LENGTH_LONG).show();
}
enqueueWork(context, UpdateService.class, JOB_ID, intent);
}
/**
* Schedule this service to update the app index while canceling any previously
* scheduled updates, according to the current preferences. Should be called
* a) at boot, b) if the preference is changed, or c) on startup, in case we get
* upgraded. It works differently on {@code android-21} and newer, versus older,
* due to the {@link JobScheduler} API handling it very nicely for us.
*
* @see <a href="https://developer.android.com/about/versions/android-5.0.html#Power">Project Volta: Scheduling jobs</a>
*/
public static void schedule(Context context) {
Preferences prefs = Preferences.get();
long interval = prefs.getUpdateInterval();
int data = prefs.getOverData();
int wifi = prefs.getOverWifi();
boolean scheduleNewJob =
interval != Preferences.UPDATE_INTERVAL_DISABLED
&& !(data == Preferences.OVER_NETWORK_NEVER && wifi == Preferences.OVER_NETWORK_NEVER);
Utils.debugLog(TAG, "Using android-21 JobScheduler for updates");
JobScheduler jobScheduler = ContextCompat.getSystemService(context, JobScheduler.class);
ComponentName componentName = new ComponentName(context, UpdateJobService.class);
JobInfo.Builder builder = new JobInfo.Builder(JOB_ID, componentName)
.setRequiresDeviceIdle(true)
.setPeriodic(interval);
if (Build.VERSION.SDK_INT >= 26) {
builder.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true);
}
if (data == Preferences.OVER_NETWORK_ALWAYS && wifi == Preferences.OVER_NETWORK_ALWAYS) {
builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
} else {
builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
}
jobScheduler.cancel(JOB_ID);
if (scheduleNewJob) {
jobScheduler.schedule(builder.build());
Utils.debugLog(TAG, "Update scheduler alarm set");
} else {
Utils.debugLog(TAG, "Update scheduler alarm not set");
}
}
/**
* Whether or not a repo update is currently in progress. Used to show feedback throughout
* the app to users, so they know something is happening.
*
* @see <a href="https://stackoverflow.com/a/608600">set a global variable when it is running that your client can check</a>
*/
public static boolean isUpdating() {
return updateService != null;
}
public static void stopNow() {
if (updateService != null) {
updateService.stopSelf(JOB_ID);
updateService = null;
}
}
/**
* Return the repos in the {@code repos} {@link List} that have either a
* local canonical URL or a local mirror URL. These are repos that can be
* updated and used without using the Internet.
*/
public static List<Repository> getLocalRepos(List<Repository> repos) {
ArrayList<Repository> localRepos = new ArrayList<>();
for (Repository repo : repos) {
if (isLocalRepoAddress(repo.getAddress())) {
localRepos.add(repo);
} else {
for (Mirror mirror : repo.getMirrors()) {
if (!mirror.isHttp()) {
localRepos.add(repo);
break;
}
}
}
}
return localRepos;
}
@Override
public void onCreate() {
super.onCreate();
updateService = this;
db = DBHelper.getDb(getApplicationContext());
notificationManager = ContextCompat.getSystemService(this, NotificationManager.class);
notificationBuilder = new NotificationCompat.Builder(this, NotificationHelper.CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_refresh)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentTitle(getString(R.string.banner_updating_repositories));
appUpdateStatusManager = AppUpdateStatusManager.getInstance(this);
}
@Override
public void onDestroy() {
super.onDestroy();
notificationManager.cancel(NOTIFY_ID_UPDATING);
LocalBroadcastManager.getInstance(this).unregisterReceiver(updateStatusReceiver);
updateService = null;
}
public static void sendStatus(Context context, int statusCode) {
sendStatus(context, statusCode, null, -1);
}
public static void sendStatus(Context context, int statusCode, String message) {
sendStatus(context, statusCode, message, -1);
}
public static void sendStatus(Context context, int statusCode, String message, int progress) {
Intent intent = new Intent(LOCAL_ACTION_STATUS);
intent.putExtra(EXTRA_STATUS_CODE, statusCode);
if (!TextUtils.isEmpty(message)) {
intent.putExtra(EXTRA_MESSAGE, message);
}
intent.putExtra(EXTRA_PROGRESS, progress);
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
private void sendRepoErrorStatus(int statusCode, ArrayList<CharSequence> repoErrors) {
Intent intent = new Intent(LOCAL_ACTION_STATUS);
intent.putExtra(EXTRA_STATUS_CODE, statusCode);
intent.putExtra(EXTRA_REPO_ERRORS, repoErrors.toArray(new CharSequence[repoErrors.size()]));
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
}
// For receiving results from the UpdateService when we've told it to
// update in response to a user request.
private final BroadcastReceiver updateStatusReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (TextUtils.isEmpty(action)) {
return;
}
if (!action.equals(LOCAL_ACTION_STATUS)) {
return;
}
final String message = intent.getStringExtra(EXTRA_MESSAGE);
int resultCode = intent.getIntExtra(EXTRA_STATUS_CODE, -1);
int progress = intent.getIntExtra(EXTRA_PROGRESS, -1);
String text;
switch (resultCode) {
case STATUS_INFO:
notificationBuilder.setContentText(message)
.setCategory(NotificationCompat.CATEGORY_SERVICE);
if (progress > -1) {
notificationBuilder.setProgress(100, progress, false);
} else {
notificationBuilder.setProgress(100, 0, true);
}
setNotification();
break;
case STATUS_ERROR_GLOBAL:
text = context.getString(R.string.global_error_updating_repos, message);
notificationBuilder.setContentText(text)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setSmallIcon(android.R.drawable.ic_dialog_alert);
setNotification();
Toast.makeText(context, text, Toast.LENGTH_LONG).show();
break;
case STATUS_ERROR_LOCAL:
case STATUS_ERROR_LOCAL_SMALL:
StringBuilder msgBuilder = new StringBuilder();
CharSequence[] repoErrors = intent.getCharSequenceArrayExtra(EXTRA_REPO_ERRORS);
for (CharSequence error : repoErrors) {
if (msgBuilder.length() > 0) msgBuilder.append('\n');
msgBuilder.append(error);
}
if (resultCode == STATUS_ERROR_LOCAL_SMALL) {
msgBuilder.append('\n').append(context.getString(R.string.all_other_repos_fine));
}
text = msgBuilder.toString();
notificationBuilder.setContentText(text)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setSmallIcon(android.R.drawable.ic_dialog_info);
setNotification();
Toast.makeText(context, text, Toast.LENGTH_LONG).show();
break;
case STATUS_COMPLETE_WITH_CHANGES:
break;
case STATUS_COMPLETE_AND_SAME:
text = context.getString(R.string.repos_unchanged);
notificationBuilder.setContentText(text)
.setCategory(NotificationCompat.CATEGORY_SERVICE);
setNotification();
break;
}
}
};
private void setNotification() {
if (Preferences.get().isUpdateNotificationEnabled()) {
notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build());
}
}
private static boolean isLocalRepoAddress(String address) {
return address != null &&
(address.startsWith(BluetoothDownloader.SCHEME)
|| address.startsWith(ContentResolver.SCHEME_CONTENT)
|| address.startsWith(ContentResolver.SCHEME_FILE));
}
@Override
protected void onHandleWork(@NonNull Intent intent) {
Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
final long startTime = System.currentTimeMillis();
boolean manualUpdate = intent.getBooleanExtra(EXTRA_MANUAL_UPDATE, false);
boolean forcedUpdate = intent.getBooleanExtra(EXTRA_FORCED_UPDATE, false);
long repoId = intent.getLongExtra(EXTRA_REPO_ID, -1);
String fingerprint = intent.getStringExtra(EXTRA_REPO_FINGERPRINT);
String address = intent.getDataString();
try {
final Preferences fdroidPrefs = Preferences.get();
// always get repos fresh from DB, because
// * when an update is requested early at app start, the repos above might not be available, yet
// * when an update is requested when adding a new repo, it might not be in the FDroidApp list, yet
List<Repository> repos = db.getRepositoryDao().getRepositories();
// See if it's time to actually do anything yet...
int netState = ConnectivityMonitorService.getNetworkState(this);
if (isLocalRepoAddress(address)) {
Utils.debugLog(TAG, "skipping internet check, this is local: " + address);
} else if (netState == ConnectivityMonitorService.FLAG_NET_UNAVAILABLE) {
// keep track of repos that have a local copy in case internet is not available
List<Repository> localRepos = getLocalRepos(repos);
if (localRepos.size() > 0) {
repos = localRepos;
} else {
Utils.debugLog(TAG, "No internet, cannot update");
if (manualUpdate) {
Utils.showToastFromService(this, getString(R.string.warning_no_internet), Toast.LENGTH_SHORT);
}
return;
}
} else if ((manualUpdate || forcedUpdate) && fdroidPrefs.isOnDemandDownloadAllowed()) {
Utils.debugLog(TAG, "manually requested or forced update");
if (forcedUpdate) {
DBHelper.resetRepos(this);
// TODO check if we still need something like this:
// InstalledAppProviderService.compareToPackageManager(this);
}
} else if (!fdroidPrefs.isBackgroundDownloadAllowed() && !fdroidPrefs.isOnDemandDownloadAllowed()) {
Utils.debugLog(TAG, "don't run update");
return;
}
setNotification();
LocalBroadcastManager.getInstance(this).registerReceiver(updateStatusReceiver,
new IntentFilter(LOCAL_ACTION_STATUS));
int unchangedRepos = 0;
int updatedRepos = 0;
int errorRepos = 0;
ArrayList<CharSequence> repoErrors = new ArrayList<>();
boolean changes = false;
boolean singleRepoUpdate = !TextUtils.isEmpty(address) || repoId > 0;
for (final Repository repo : repos) {
if (!repo.getEnabled()) continue;
if (singleRepoUpdate && !repo.getAddress().equals(address) && repo.getRepoId() != repoId) {
unchangedRepos++;
continue;
}
// TODO reject update if repo.getLastUpdated() is too recent
sendStatus(this, STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.getAddress()));
final RepoUriBuilder repoUriBuilder = (repository, pathElements) -> {
String address1 = Utils.getRepoAddress(repository);
return Utils.getUri(address1, pathElements);
};
final CompatibilityChecker compatChecker =
new CompatibilityCheckerImpl(getPackageManager(), Preferences.get().forceTouchApps());
final UpdateServiceListener listener = new UpdateServiceListener(UpdateService.this);
final File cacheDir = getApplicationContext().getCacheDir();
final IndexUpdateResult result;
if (Preferences.get().isForceOldIndexEnabled()) {
final TempFileProvider tempFileProvider = () ->
File.createTempFile("dl-", "", cacheDir);
final IndexV1Updater updater = new IndexV1Updater(db, tempFileProvider,
DownloaderFactory.INSTANCE, repoUriBuilder, compatChecker, listener);
result = updater.updateNewRepo(repo, fingerprint);
} else {
final RepoUpdater updater = new RepoUpdater(cacheDir, db,
DownloaderFactory.INSTANCE, repoUriBuilder, compatChecker, listener);
result = updater.update(repo, fingerprint);
}
if (result instanceof IndexUpdateResult.Unchanged) {
unchangedRepos++;
} else if (result instanceof IndexUpdateResult.Processed) {
updatedRepos++;
changes = true;
} else if (result instanceof IndexUpdateResult.Error) {
errorRepos++;
Exception e = ((IndexUpdateResult.Error) result).getE();
Throwable cause = e.getCause();
String repoName = repo.getName(App.getLocales());
String repoPrefix = repoName == null ? "" : repoName + ": ";
if (cause == null) {
repoErrors.add(repoPrefix + e.getLocalizedMessage());
} else {
repoErrors.add(repoPrefix + e.getLocalizedMessage() + "" +
cause.getLocalizedMessage());
}
Log.e(TAG, "Error updating repository " + repo.getAddress(), e);
}
// now that downloading the index is done, start downloading updates
// TODO why are we checking for updates several times (in loop and below)
if (changes && fdroidPrefs.isAutoDownloadEnabled() && fdroidPrefs.isBackgroundDownloadAllowed()) {
autoDownloadUpdates(this);
}
}
if (!changes) {
Utils.debugLog(TAG, "Not checking app details or compatibility, because repos were up to date.");
} else if (fdroidPrefs.isUpdateNotificationEnabled() && !fdroidPrefs.isAutoDownloadEnabled()) {
appUpdateStatusManager.checkForUpdates();
}
fdroidPrefs.setLastUpdateCheck(System.currentTimeMillis());
if (errorRepos == 0) {
if (changes) {
sendStatus(this, STATUS_COMPLETE_WITH_CHANGES);
} else {
sendStatus(this, STATUS_COMPLETE_AND_SAME);
}
} else {
if (updatedRepos + unchangedRepos == 0) {
sendRepoErrorStatus(STATUS_ERROR_LOCAL, repoErrors);
} else {
sendRepoErrorStatus(STATUS_ERROR_LOCAL_SMALL, repoErrors);
}
}
} catch (Exception e) {
Log.e(TAG, "Exception during update processing", e);
sendStatus(this, STATUS_ERROR_GLOBAL, e.getMessage());
}
long time = System.currentTimeMillis() - startTime;
Log.i(TAG, "Updating repo(s) complete, took " + time / 1000 + " seconds to complete.");
}
/**
* Queues all apps needing update. If this app itself (e.g. F-Droid) needs
* to be updated, it is queued last.
*/
public static Disposable autoDownloadUpdates(Context context) {
DbUpdateChecker updateChecker = new DbUpdateChecker(DBHelper.getDb(context), context.getPackageManager());
List<String> releaseChannels = Preferences.get().getBackendReleaseChannels();
return Single.fromCallable(() -> updateChecker.getUpdatableApps(releaseChannels))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(updatableApps -> downloadUpdates(context, updatableApps));
}
private static void downloadUpdates(Context context, List<UpdatableApp> apps) {
String ourPackageName = context.getPackageName();
App updateLastApp = null;
Apk updateLastApk = null;
for (UpdatableApp app : apps) {
// update our own APK at the end
if (TextUtils.equals(ourPackageName, app.getUpdate().getPackageName())) {
updateLastApp = new App(app);
updateLastApk = new Apk(app.getUpdate());
continue;
}
InstallManagerService.queue(context, new App(app), new Apk(app.getUpdate()));
}
if (updateLastApp != null) {
InstallManagerService.queue(context, updateLastApp, updateLastApk);
}
}
public static void reportDownloadProgress(Context context, String indexUrl,
long bytesRead, long totalBytes) {
Utils.debugLog(TAG, "Downloading " + indexUrl + "(" + bytesRead + "/" + totalBytes + ")");
String downloadedSizeFriendly = Utils.getFriendlySize(bytesRead);
int percent = -1;
if (totalBytes > 0) {
percent = Utils.getPercent(bytesRead, totalBytes);
}
String message;
if (totalBytes == -1) {
message = context.getString(R.string.status_download_unknown_size,
indexUrl, downloadedSizeFriendly);
percent = -1;
} else {
String totalSizeFriendly = Utils.getFriendlySize(totalBytes);
message = context.getString(R.string.status_download,
indexUrl, downloadedSizeFriendly, totalSizeFriendly, percent);
}
sendStatus(context, STATUS_INFO, message, percent);
}
/**
* If an updater is unable to know how many apps it has to process (i.e. it
* is streaming apps to the database or performing a large database query
* which touches all apps, but is unable to report progress), then it call
* this listener with `totalBytes = 0`. Doing so will result in a message of
* "Saving app details" sent to the user. If you know how many apps you have
* processed, then a message of "Saving app details (x/total)" is displayed.
*/
public static void reportProcessingAppsProgress(Context context, String indexUrl, int appsSaved, int totalApps) {
Utils.debugLog(TAG, "Committing " + indexUrl + "(" + appsSaved + "/" + totalApps + ")");
if (totalApps > 0) {
String message = context.getString(R.string.status_inserting_x_apps,
appsSaved, totalApps, indexUrl);
sendStatus(context, STATUS_INFO, message, Utils.getPercent(appsSaved, totalApps));
} else {
String message = context.getString(R.string.status_inserting_apps);
sendStatus(context, STATUS_INFO, message);
}
}
}