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

564 lines
22 KiB
Java

package org.fdroid.fdroid;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Parcel;
import android.os.Parcelable;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.installer.ErrorDialogActivity;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.net.DownloaderService;
import org.fdroid.fdroid.views.AppDetailsActivity;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.TaskStackBuilder;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
/**
* Manages the state of APKs that are being installed or that have updates available.
* This also ensures the state is saved across F-Droid restarts, and repopulates
* based on {@link org.fdroid.fdroid.data.Schema.InstalledAppTable} data, APKs that
* are present in the cache, and the {@code apks-pending-install}
* {@link SharedPreferences} instance.
* <p>
* As defined in {@link org.fdroid.fdroid.installer.InstallManagerService}, the
* canonical URL for the APK file to download is used as the unique ID to represent
* the status of the APK throughout F-Droid.
*
* @see org.fdroid.fdroid.installer.InstallManagerService
*/
public final class AppUpdateStatusManager {
public static final String TAG = "AppUpdateStatusManager";
/**
* Broadcast when:
* * The user clears the list of installed apps from notification manager.
* * The user clears the list of apps available to update from the notification manager.
* * A repo update is completed and a bunch of new apps are ready to be updated.
* * F-Droid is opened, and it finds a bunch of .apk files downloaded and ready to install.
*/
public static final String BROADCAST_APPSTATUS_LIST_CHANGED = "org.fdroid.fdroid.installer.appstatus.listchange";
/**
* Broadcast when an app begins the download/install process (either manually or via an automatic download).
*/
public static final String BROADCAST_APPSTATUS_ADDED = "org.fdroid.fdroid.installer.appstatus.appchange.add";
/**
* When the {@link AppUpdateStatus#status} of an app changes or the download progress for an app advances.
*/
public static final String BROADCAST_APPSTATUS_CHANGED = "org.fdroid.fdroid.installer.appstatus.appchange.change";
/**
* Broadcast when:
* * The associated app has the {@link Status#Installed} status, and the user either visits
* that apps details page or clears the individual notification for the app.
* * The download for an app is cancelled.
*/
public static final String BROADCAST_APPSTATUS_REMOVED = "org.fdroid.fdroid.installer.appstatus.appchange.remove";
public static final String EXTRA_STATUS = "status";
public static final String EXTRA_REASON_FOR_CHANGE = "reason";
public static final String REASON_READY_TO_INSTALL = "readytoinstall";
public static final String REASON_UPDATES_AVAILABLE = "updatesavailable";
public static final String REASON_CLEAR_ALL_UPDATES = "clearallupdates";
public static final String REASON_CLEAR_ALL_INSTALLED = "clearallinstalled";
public static final String REASON_REPO_DISABLED = "repodisabled";
/**
* If this is present and true, then the broadcast has been sent in response to the {@link AppUpdateStatus#status}
* changing. In comparison, if it is just the download progress of an app then this should not be true.
*/
public static final String EXTRA_IS_STATUS_UPDATE = "isstatusupdate";
private static final String LOGTAG = "AppUpdateStatusManager";
public enum Status {
PendingInstall,
DownloadInterrupted,
UpdateAvailable,
Downloading,
ReadyToInstall,
Installing,
Installed,
InstallError,
}
public static AppUpdateStatusManager getInstance(Context context) {
if (instance == null) {
instance = new AppUpdateStatusManager(context.getApplicationContext());
}
return instance;
}
private static AppUpdateStatusManager instance;
public static class AppUpdateStatus implements Parcelable {
public final App app;
public final Apk apk;
public Status status;
public PendingIntent intent;
public long progressCurrent;
public long progressMax;
public String errorText;
AppUpdateStatus(App app, Apk apk, Status status, PendingIntent intent) {
this.app = app;
this.apk = apk;
this.status = status;
this.intent = intent;
}
/**
* @return the unique ID used to represent this specific package's install process
* also known as {@code canonicalUrl}.
* @see org.fdroid.fdroid.installer.InstallManagerService
*/
public String getCanonicalUrl() {
return apk.getCanonicalUrl();
}
/**
* Dumps some information about the status for debugging purposes.
*/
public String toString() {
return app.packageName + " [Status: " + status
+ ", Progress: " + progressCurrent + " / " + progressMax + ']';
}
protected AppUpdateStatus(Parcel in) {
app = in.readParcelable(getClass().getClassLoader());
apk = in.readParcelable(getClass().getClassLoader());
intent = in.readParcelable(getClass().getClassLoader());
status = (Status) in.readSerializable();
progressCurrent = in.readLong();
progressMax = in.readLong();
errorText = in.readString();
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeParcelable(app, 0);
dest.writeParcelable(apk, 0);
dest.writeParcelable(intent, 0);
dest.writeSerializable(status);
dest.writeLong(progressCurrent);
dest.writeLong(progressMax);
dest.writeString(errorText);
}
@Override
public int describeContents() {
return 0;
}
public static final Parcelable.Creator<AppUpdateStatus> CREATOR = new Parcelable.Creator<AppUpdateStatus>() {
@Override
public AppUpdateStatus createFromParcel(Parcel in) {
return new AppUpdateStatus(in);
}
@Override
public AppUpdateStatus[] newArray(int size) {
return new AppUpdateStatus[size];
}
};
/**
* When passing to the broadcast manager, it is important to pass a copy rather than the original object.
* This is because if two status changes are noticed in the same event loop, than they will both refer
* to the same status object. The objects are not parceled until the end of the event loop, and so the first
* parceled event will refer to the updated object (with a different status) rather than the intended
* status (i.e. the one in existence when talking to the broadcast manager).
*/
public AppUpdateStatus copy() {
AppUpdateStatus copy = new AppUpdateStatus(app, apk, status, intent);
copy.errorText = errorText;
copy.progressCurrent = progressCurrent;
copy.progressMax = progressMax;
return copy;
}
}
private final Context context;
private final LocalBroadcastManager localBroadcastManager;
private final HashMap<String, AppUpdateStatus> appMapping = new HashMap<>();
private boolean isBatchUpdating;
private AppUpdateStatusManager(Context context) {
this.context = context;
localBroadcastManager = LocalBroadcastManager.getInstance(context.getApplicationContext());
}
public void removeAllByRepo(Repo repo) {
boolean hasRemovedSome = false;
Iterator<AppUpdateStatus> it = getAll().iterator();
while (it.hasNext()) {
AppUpdateStatus status = it.next();
if (status.apk.repoId == repo.getId()) {
it.remove();
hasRemovedSome = true;
}
}
if (hasRemovedSome) {
notifyChange(REASON_REPO_DISABLED);
}
}
@Nullable
public AppUpdateStatus get(String canonicalUrl) {
synchronized (appMapping) {
return appMapping.get(canonicalUrl);
}
}
public Collection<AppUpdateStatus> getAll() {
synchronized (appMapping) {
return appMapping.values();
}
}
/**
* Get all entries associated with a package name. There may be several.
*
* @param packageName Package name of the app
* @return A list of entries, or an empty list
*/
public Collection<AppUpdateStatus> getByPackageName(String packageName) {
ArrayList<AppUpdateStatus> returnValues = new ArrayList<>();
synchronized (appMapping) {
for (AppUpdateStatus entry : appMapping.values()) {
if (entry.apk.packageName.equals(packageName)) {
returnValues.add(entry);
}
}
}
return returnValues;
}
private void updateApkInternal(@NonNull AppUpdateStatus entry, @NonNull Status status, PendingIntent intent) {
Utils.debugLog(LOGTAG, "Update APK " + entry.apk.apkName + " state to " + status.name());
boolean isStatusUpdate = entry.status != status;
entry.status = status;
entry.intent = intent;
setEntryContentIntentIfEmpty(entry);
notifyChange(entry, isStatusUpdate);
if (status == Status.Installed) {
InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl());
}
}
private void addApkInternal(@NonNull Apk apk, @NonNull Status status, PendingIntent intent) {
Utils.debugLog(LOGTAG, "Add APK " + apk.apkName + " with state " + status.name());
AppUpdateStatus entry = createAppEntry(apk, status, intent);
setEntryContentIntentIfEmpty(entry);
appMapping.put(entry.getCanonicalUrl(), entry);
notifyAdd(entry);
if (status == Status.Installed) {
InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl());
}
}
private void notifyChange(String reason) {
if (!isBatchUpdating) {
Intent intent = new Intent(BROADCAST_APPSTATUS_LIST_CHANGED);
intent.putExtra(EXTRA_REASON_FOR_CHANGE, reason);
localBroadcastManager.sendBroadcast(intent);
}
}
private void notifyAdd(AppUpdateStatus entry) {
if (!isBatchUpdating) {
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_ADDED);
broadcastIntent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
localBroadcastManager.sendBroadcast(broadcastIntent);
}
}
private void notifyChange(AppUpdateStatus entry, boolean isStatusUpdate) {
if (!isBatchUpdating) {
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_CHANGED);
broadcastIntent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
broadcastIntent.putExtra(EXTRA_IS_STATUS_UPDATE, isStatusUpdate);
localBroadcastManager.sendBroadcast(broadcastIntent);
}
}
private void notifyRemove(AppUpdateStatus entry) {
if (!isBatchUpdating) {
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_REMOVED);
broadcastIntent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
localBroadcastManager.sendBroadcast(broadcastIntent);
}
}
private AppUpdateStatus createAppEntry(Apk apk, Status status, PendingIntent intent) {
synchronized (appMapping) {
ContentResolver resolver = context.getContentResolver();
App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repoId);
AppUpdateStatus ret = new AppUpdateStatus(app, apk, status, intent);
appMapping.put(apk.getCanonicalUrl(), ret);
return ret;
}
}
public void addApks(List<Apk> apksToUpdate, Status status) {
startBatchUpdates();
for (Apk apk : apksToUpdate) {
addApk(apk, status, null);
}
endBatchUpdates(status);
}
/**
* Add an Apk to the AppUpdateStatusManager manager (or update it if we already know about it).
*
* @param apk The apk to add.
* @param status The current status of the app
* @param pendingIntent Action when notification is clicked. Can be null for default action(s)
*/
public void addApk(Apk apk, @NonNull Status status, @Nullable PendingIntent pendingIntent) {
if (apk == null) {
return;
}
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(apk.getCanonicalUrl());
if (entry != null) {
updateApkInternal(entry, status, pendingIntent);
} else {
addApkInternal(apk, status, pendingIntent);
}
}
}
/**
* @param pendingIntent Action when notification is clicked. Can be null for default action(s)
*/
public void updateApk(String canonicalUrl, @NonNull Status status, @Nullable PendingIntent pendingIntent) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(canonicalUrl);
if (entry != null) {
updateApkInternal(entry, status, pendingIntent);
}
}
}
@Nullable
public Apk getApk(String canonicalUrl) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(canonicalUrl);
if (entry != null) {
return entry.apk;
}
return null;
}
}
/**
* Remove an APK from being tracked, since it is now considered {@link Status#Installed}
*
* @param canonicalUrl the unique ID for the install process
* @see org.fdroid.fdroid.installer.InstallManagerService
*/
public void removeApk(String canonicalUrl) {
synchronized (appMapping) {
InstallManagerService.removePendingInstall(context, canonicalUrl);
AppUpdateStatus entry = appMapping.remove(canonicalUrl);
if (entry != null) {
Utils.debugLog(LOGTAG, "Remove APK " + entry.apk.apkName);
notifyRemove(entry);
}
}
}
public void refreshApk(String canonicalUrl) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(canonicalUrl);
if (entry != null) {
Utils.debugLog(LOGTAG, "Refresh APK " + entry.apk.apkName);
notifyChange(entry, true);
}
}
}
public void updateApkProgress(String canonicalUrl, long max, long current) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(canonicalUrl);
if (entry != null) {
entry.progressMax = max;
entry.progressCurrent = current;
notifyChange(entry, false);
}
}
}
/**
* @param errorText If null, then it is likely because the user cancelled the download.
*/
public void setDownloadError(String canonicalUrl, @Nullable String errorText) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(canonicalUrl);
if (entry != null) {
entry.status = Status.DownloadInterrupted;
entry.errorText = errorText;
entry.intent = null;
notifyChange(entry, true);
removeApk(canonicalUrl);
}
}
}
public void setApkError(Apk apk, String errorText) {
synchronized (appMapping) {
AppUpdateStatus entry = appMapping.get(apk.getCanonicalUrl());
if (entry == null) {
entry = createAppEntry(apk, Status.InstallError, null);
}
entry.status = Status.InstallError;
entry.errorText = errorText;
entry.intent = getAppErrorIntent(entry);
notifyChange(entry, false);
InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl());
}
}
private void startBatchUpdates() {
synchronized (appMapping) {
isBatchUpdating = true;
}
}
private void endBatchUpdates(Status status) {
synchronized (appMapping) {
isBatchUpdating = false;
String reason = null;
if (status == Status.ReadyToInstall) {
reason = REASON_READY_TO_INSTALL;
} else if (status == Status.UpdateAvailable) {
reason = REASON_UPDATES_AVAILABLE;
}
notifyChange(reason);
}
}
@SuppressWarnings("LineLength")
void clearAllUpdates() {
synchronized (appMapping) {
for (Iterator<Map.Entry<String, AppUpdateStatus>> it = appMapping.entrySet().iterator(); it.hasNext(); ) { // NOCHECKSTYLE EmptyForIteratorPad
Map.Entry<String, AppUpdateStatus> entry = it.next();
if (entry.getValue().status != Status.Installed) {
it.remove();
}
}
notifyChange(REASON_CLEAR_ALL_UPDATES);
}
}
@SuppressWarnings("LineLength")
void clearAllInstalled() {
synchronized (appMapping) {
for (Iterator<Map.Entry<String, AppUpdateStatus>> it = appMapping.entrySet().iterator(); it.hasNext(); ) { // NOCHECKSTYLE EmptyForIteratorPad
Map.Entry<String, AppUpdateStatus> entry = it.next();
if (entry.getValue().status == Status.Installed) {
it.remove();
}
}
notifyChange(REASON_CLEAR_ALL_INSTALLED);
}
}
/**
* If the {@link PendingIntent} aimed at {@link Notification.Builder#setContentIntent(PendingIntent)}
* is not set, then create a default one. The goal is to link the notification
* to the most relevant action, like the installer if the APK is downloaded, or the launcher once
* installed, if possible, or other relevant action. If there is no app launch
* {@code PendingIntent}, the app is probably not launchable, e.g. its a keyboard.
* If there is not an {@code PendingIntent} to install the app, this creates an {@code PendingIntent}
* to open up the app details page for the app. From there, the user can hit "install".
* <p>
* Before {@code android-11}, a {@code ContentIntent} was required in every
* {@link Notification}. This generates a boilerplate one for places where
* there isn't an obvious one.
*/
private void setEntryContentIntentIfEmpty(AppUpdateStatus entry) {
if (entry.intent != null) {
return;
}
switch (entry.status) {
case UpdateAvailable:
case ReadyToInstall:
entry.intent = getAppDetailsIntent(entry.apk);
break;
case InstallError:
entry.intent = getAppErrorIntent(entry);
break;
case Installed:
PackageManager pm = context.getPackageManager();
Intent intentObject = pm.getLaunchIntentForPackage(entry.app.packageName);
if (intentObject != null) {
entry.intent = PendingIntent.getActivity(context, 0, intentObject, 0);
} else {
entry.intent = getAppDetailsIntent(entry.apk);
}
break;
}
}
/**
* Get a {@link PendingIntent} for a {@link Notification} to send when it
* is clicked. {@link AppDetailsActivity} handles {@code Intent}s that are missing
* or bad {@link AppDetailsActivity#EXTRA_APPID}, so it does not need to be checked
* here.
*/
private PendingIntent getAppDetailsIntent(Apk apk) {
Intent notifyIntent = new Intent(context, AppDetailsActivity.class)
.putExtra(AppDetailsActivity.EXTRA_APPID, apk.packageName);
return TaskStackBuilder.create(context)
.addParentStack(AppDetailsActivity.class)
.addNextIntent(notifyIntent)
.getPendingIntent(apk.packageName.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT);
}
private PendingIntent getAppErrorIntent(AppUpdateStatus entry) {
String title = String.format(context.getString(R.string.install_error_notify_title), entry.app.name);
Intent errorDialogIntent = new Intent(context, ErrorDialogActivity.class)
.putExtra(ErrorDialogActivity.EXTRA_TITLE, title)
.putExtra(ErrorDialogActivity.EXTRA_MESSAGE, entry.errorText);
return PendingIntent.getActivity(
context,
0,
errorDialogIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
}