diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index 7624f710b..2507c46d2 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -220,6 +220,8 @@ public class FDroidApp extends Application { curTheme = Preferences.get().getTheme(); Preferences.get().configureProxy(); + NotificationHelper.init(getApplicationContext()); + InstalledAppProviderService.compareToPackageManager(this); // If the user changes the preference to do with filtering rooted apps, diff --git a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java new file mode 100644 index 000000000..18c8190f5 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java @@ -0,0 +1,522 @@ +package org.fdroid.fdroid; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.Typeface; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.app.TaskStackBuilder; +import android.support.v4.util.LongSparseArray; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.StyleSpan; + +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.ImageSize; + +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +public class NotificationHelper { + + private static final String BROADCAST_NOTIFICATIONS_UPDATES_CLEARED = "org.fdroid.fdroid.installer.notifications.updates.cleared"; + private static final String BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED = "org.fdroid.fdroid.installer.notifications.installed.cleared"; + private static final String BROADCAST_NOTIFICATIONS_NOTIFICATION_DELETED = "org.fdroid.fdroid.installer.notifications.deleted"; + + private static final int NOTIFY_ID_UPDATES = 4711; + private static final int NOTIFY_ID_INSTALLED = 4712; + + private static final int MAX_UPDATES_TO_SHOW = 5; + private static final int MAX_INSTALLED_TO_SHOW = 10; + + private static final String EXTRA_NOTIFICATION_TAG = "tag"; + private static final String GROUP_UPDATES = "updates"; + private static final String GROUP_INSTALLED = "installed"; + + public enum Status { + UpdateAvailable, + Downloading, + ReadyToInstall, + Installing, + Installed, + Error + } + + private static NotificationHelper instance; + + public static void init(Context context) { + instance = new NotificationHelper(context); + } + + private static NotificationHelper getInstance() { + return instance; + } + + private class AppEntry { + App app; + Apk apk; + Status status; + PendingIntent intent; + int progressCurrent; + int progressMax; + + AppEntry(App app, Apk apk, Status status, PendingIntent intent) { + this.app = app; + this.apk = apk; + this.status = status; + this.intent = intent; + } + + String getTag() { + return apk.getUrl(); + } + + int getId() { + return getTag().hashCode(); + } + } + + private final Context context; + private final NotificationManagerCompat notificationManager; + private HashMap appMapping; + private boolean isBatchUpdating; + private ArrayList updates; + private ArrayList installed; + + private NotificationHelper(Context context) { + this.context = context; + notificationManager = NotificationManagerCompat.from(context); + + // We need to listen to when notifications are cleared, so that we "forget" all that we currently know about updates + // and installs. + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_NOTIFICATIONS_UPDATES_CLEARED); + filter.addAction(BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED); + filter.addAction(BROADCAST_NOTIFICATIONS_NOTIFICATION_DELETED); + BroadcastReceiver mReceiverNotificationsCleared = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED: + clearAllInstalledInternal(); + break; + case BROADCAST_NOTIFICATIONS_UPDATES_CLEARED: + clearAllUpdatesInternal(); + break; + case BROADCAST_NOTIFICATIONS_NOTIFICATION_DELETED: + String id = intent.getStringExtra(EXTRA_NOTIFICATION_TAG); + // TODO + break; + } + } + }; + context.registerReceiver(mReceiverNotificationsCleared, filter); + appMapping = new HashMap<>(); + updates = new ArrayList<>(); + installed = new ArrayList<>(); + } + + private void setApkInternal(Apk apk, Status status, PendingIntent intent) { + if (apk == null) { + return; + } + + AppEntry entry = appMapping.get(apk.getUrl()); + if (status == null) { + // Remove + if (entry != null) { + appMapping.remove(apk.getUrl()); + notificationManager.cancel(entry.getTag(), entry.getId()); + } + } else if (entry != null) { + // Update + boolean isStatusUpdate = (entry.status != status); + entry.status = status; + entry.intent = intent; + createNotificationForAppEntry(entry); + if (isStatusUpdate) { + updateSummaryNotifications(); + } + } else { + // Add + ContentResolver resolver = context.getContentResolver(); + App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo); + entry = new AppEntry(app, apk, status, intent); + appMapping.put(apk.getUrl(), entry); + createNotificationForAppEntry(entry); + updateSummaryNotifications(); + } + } + + private void setApkProgressInternal(Apk apk, int max, int current) { + if (appMapping.get(apk.getUrl()) != null) { + AppEntry entry = appMapping.get(apk.getUrl()); + entry.progressMax = max; + entry.progressCurrent = current; + createNotificationForAppEntry(entry); + } + } + + private void clearAllUpdatesInternal() { + for(Iterator> it = appMapping.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if(entry.getValue().status != Status.Installed) { + it.remove(); + } + } + } + + private void clearAllInstalledInternal() { + for(Iterator> it = appMapping.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if(entry.getValue().status == Status.Installed) { + it.remove(); + } + } + } + + private void updateSummaryNotifications() { + if (!isBatchUpdating) { + // Get the list of updates and installed available + updates.clear(); + installed.clear(); + for (Iterator> it = appMapping.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (entry.getValue().status != Status.Installed) { + updates.add(entry.getValue()); + } else { + installed.add(entry.getValue()); + } + } + + NotificationCompat.Builder builder; + if (updates.size() == 0) { + // No updates, remove summary + notificationManager.cancel(GROUP_UPDATES, NOTIFY_ID_UPDATES); + } else { + builder = createUpdateSummaryNotification(updates); + notificationManager.notify(GROUP_UPDATES, NOTIFY_ID_UPDATES, builder.build()); + } + if (installed.size() == 0) { + // No installed, remove summary + notificationManager.cancel(GROUP_INSTALLED, NOTIFY_ID_INSTALLED); + } else { + builder = createInstalledSummaryNotification(installed); + notificationManager.notify(GROUP_INSTALLED, NOTIFY_ID_INSTALLED, builder.build()); + } + } + } + + private void createNotificationForAppEntry(AppEntry entry) { + NotificationCompat.Builder builder; + if (entry.status == Status.Installed) { + builder = createInstalledNotification(entry); + } else { + builder = createUpdateNotification(entry); + } + notificationManager.notify(entry.getTag(), entry.getId(), builder.build()); + } + + /** + * Add an Apk to the notifications manager. + * @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 static void setApk(Apk apk, Status status, PendingIntent pendingIntent) { + getInstance().setApkInternal(apk, status, pendingIntent); + } + + public static void removeApk(Apk apk) { + getInstance().setApkInternal(apk, null, null); + } + + public static void setApkProgress(Apk apk, int max, int current) { + getInstance().setApkProgressInternal(apk, max, current); + } + + public static void startBatchUpdates() { + getInstance().isBatchUpdating = true; + } + + public static void endBatchUpdates() { + getInstance().isBatchUpdating = false; + getInstance().updateSummaryNotifications(); + } + + public static void clearAllUpdates() { + getInstance().clearAllUpdatesInternal(); + } + + public static void clearAllInstalled() { + getInstance().clearAllInstalledInternal(); + } + + private NotificationCompat.Action getAction(AppEntry entry) { + if (entry.status == Status.UpdateAvailable) { + // Make sure we have an intent to install the app. If not set, we create an intent + // to open up the app details page for the app. From there, the user can hit "install" + PendingIntent intent = entry.intent; + if (intent == null) { + intent = getAppDetailsIntent(0, entry.apk); + } + return new NotificationCompat.Action(R.drawable.ic_notify_update_24dp, "Update", intent); + } else if (entry.status == Status.Downloading || entry.status == Status.Installing) { + PendingIntent intent = entry.intent; + if (intent != null) { + return new NotificationCompat.Action(R.drawable.ic_notify_cancel_24dp, "Cancel", intent); + } + } else if (entry.status == Status.ReadyToInstall) { + // Make sure we have an intent to install the app. If not set, we create an intent + // to open up the app details page for the app. From there, the user can hit "install" + PendingIntent intent = entry.intent; + if (intent == null) { + intent = getAppDetailsIntent(0, entry.apk); + } + return new NotificationCompat.Action(R.drawable.ic_notify_install_24dp, "Install", intent); + } + return null; + } + + private String getSingleItemTitleString(App app, Status status) { + switch (status) { + case UpdateAvailable: + return "Update Available"; + case Downloading: + return app.name; + case ReadyToInstall: + return "Update ready to install"; // TODO - "Update"? Should just be "ready to install"? + case Installing: + return app.name; + case Installed: + return app.name; + } + return ""; + } + + private String getSingleItemContentString(App app, Status status) { + switch (status) { + case UpdateAvailable: + return app.name; + case Downloading: + return String.format("Downloading update for \"%s\"...", app.name); + case ReadyToInstall: + return app.name; + case Installing: + return String.format("Installing \"%s\"...", app.name); + case Installed: + return "Successfully installed"; + } + return ""; + } + + private String getMultiItemContentString(App app, Status status) { + switch (status) { + case UpdateAvailable: + return "Update available"; + case Downloading: + return "Downloading update..."; + case ReadyToInstall: + return "Ready to install"; + case Installing: + return "Installing"; + case Installed: + return "Successfully installed"; + } + return ""; + } + + /** + * Get a {@link PendingIntent} for a {@link Notification} to send when it + * is clicked. {@link AppDetails} handles {@code Intent}s that are missing + * or bad {@link AppDetails#EXTRA_APPID}, so it does not need to be checked + * here. + */ + private PendingIntent getAppDetailsIntent(int requestCode, Apk apk) { + Intent notifyIntent = new Intent(context, AppDetails.class) + .putExtra(AppDetails.EXTRA_APPID, apk.packageName); + return TaskStackBuilder.create(context) + .addParentStack(AppDetails.class) + .addNextIntent(notifyIntent) + .getPendingIntent(requestCode, 0); + } + + private NotificationCompat.Builder createUpdateNotification(AppEntry entry) { + App app = entry.app; + Status status = entry.status; + + // TODO - async image loading + int largeIconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); + Bitmap iconLarge = ImageLoader.getInstance().loadImageSync(app.iconUrl, new ImageSize(largeIconSize, largeIconSize)); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setLargeIcon(iconLarge) + .setSmallIcon(R.drawable.ic_stat_notify_updates) + .setContentTitle(getSingleItemTitleString(app, status)) + .setContentText(getSingleItemContentString(app, status)) + .setGroup(GROUP_UPDATES); + + // Handle actions + // + NotificationCompat.Action action = getAction(entry); + if (action != null) { + builder.addAction(action); + // TODO - also click on whole item? + builder.setContentIntent(action.getActionIntent()); + } else if (entry.intent != null) { + builder.setContentIntent(entry.intent); + } + + // Handle progress bar (for some states) + // + if (status == Status.Downloading) { + if (entry.progressMax == 0) + builder.setProgress(100, 0, true); + else + builder.setProgress(entry.progressMax, entry.progressCurrent, false); + } else if (status == Status.Installing) { + builder.setProgress(100, 0, true); // indeterminate bar + } + + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_NOTIFICATION_DELETED); + intentDeleted.putExtra(EXTRA_NOTIFICATION_TAG, entry.getId()); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder; + } + + private NotificationCompat.Builder createUpdateSummaryNotification(ArrayList updates) { + String title = String.format("%d Updates", updates.size()); + StringBuilder text = new StringBuilder(); + + NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + inboxStyle.setBigContentTitle(title); + + for (int i = 0; i < MAX_UPDATES_TO_SHOW && i < updates.size(); i++) { + AppEntry entry = updates.get(i); + App app = entry.app; + Status status = entry.status; + + String content = getMultiItemContentString(app, status); + SpannableStringBuilder sb = new SpannableStringBuilder(app.name); + sb.setSpan(new StyleSpan(Typeface.BOLD), 0, sb.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + sb.append(" "); + sb.append(content); + inboxStyle.addLine(sb); + + if (text.length() > 0) + text.append(", "); + text.append(app.name); + } + + //if (updates.size() > MAX_UPDATES_TO_SHOW) { + // int diff = updates.size() - MAX_UPDATES_TO_SHOW; + // inboxStyle.setSummaryText(context.getString(R.string.update_notification_more, diff)); + //} + + inboxStyle.setSummaryText(title); + + + // Intent to open main app list + Intent intentObject = new Intent(context, FDroid.class); + PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(piAction) + .setStyle(inboxStyle) + .setGroup(GROUP_UPDATES) + .setGroupSummary(true); + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_UPDATES_CLEARED); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder; + } + + private NotificationCompat.Builder createInstalledNotification(AppEntry entry) { + App app = entry.app; + + int largeIconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); + Bitmap iconLarge = ImageLoader.getInstance().loadImageSync(app.iconUrl, new ImageSize(largeIconSize, largeIconSize)); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setLargeIcon(iconLarge) + .setSmallIcon(R.drawable.ic_stat_notify_updates) + .setContentTitle(app.name) + .setContentText("Successfully Installed") + .setGroup(GROUP_INSTALLED); + + PackageManager pm = context.getPackageManager(); + Intent intentObject = pm.getLaunchIntentForPackage(app.packageName); + PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0); + builder.setContentIntent(piAction); + + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_NOTIFICATION_DELETED); + intentDeleted.putExtra(EXTRA_NOTIFICATION_TAG, entry.getId()); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder; + } + + private NotificationCompat.Builder createInstalledSummaryNotification(ArrayList installed) { + String title = String.format("%d Apps Installed", installed.size()); + StringBuilder text = new StringBuilder(); + + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle(); + bigTextStyle.setBigContentTitle(title); + + for (int i = 0; i < MAX_INSTALLED_TO_SHOW && i < installed.size(); i++) { + AppEntry entry = installed.get(i); + App app = entry.app; + if (text.length() > 0) + text.append(", "); + text.append(app.name); + } + bigTextStyle.bigText(text); + if (installed.size() > MAX_INSTALLED_TO_SHOW) { + int diff = installed.size() - MAX_INSTALLED_TO_SHOW; + bigTextStyle.setSummaryText(context.getString(R.string.update_notification_more, diff)); + } + + // Intent to open main app list + Intent intentObject = new Intent(context, FDroid.class); + PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(piAction) + .setGroup(GROUP_INSTALLED) + .setGroupSummary(true); + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder; + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index fbf780caa..ce83afce8 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -520,6 +520,19 @@ public class UpdateService extends IntentService { } private void showAppUpdatesNotification(Cursor hasUpdates) { + + if (hasUpdates != null) { + hasUpdates.moveToFirst(); + NotificationHelper.startBatchUpdates(); + for (int i = 0; i < Math.min(MAX_UPDATES_TO_SHOW, hasUpdates.getCount()); i++) { + App app = new App(hasUpdates); + hasUpdates.moveToNext(); + Apk apk = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); + NotificationHelper.setApk(apk, NotificationHelper.Status.UpdateAvailable, null); + } + NotificationHelper.endBatchUpdates(); + } + Utils.debugLog(TAG, "Notifying " + hasUpdates.getCount() + " updates."); final int icon = Build.VERSION.SDK_INT >= 11 ? R.drawable.ic_stat_notify_updates : R.drawable.ic_launcher; diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java index 226aafd6e..e50d2ccec 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -22,6 +22,7 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.io.filefilter.WildcardFileFilter; import org.fdroid.fdroid.AppDetails; import org.fdroid.fdroid.Hasher; +import org.fdroid.fdroid.NotificationHelper; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.PackageManagerCompat; @@ -98,7 +99,6 @@ public class InstallManagerService extends Service { private static final HashMap ACTIVE_APPS = new HashMap<>(3); private LocalBroadcastManager localBroadcastManager; - private NotificationManager notificationManager; /** * This service does not use binding, so no need to implement this method @@ -113,7 +113,6 @@ public class InstallManagerService extends Service { super.onCreate(); Utils.debugLog(TAG, "creating Service"); localBroadcastManager = LocalBroadcastManager.getInstance(this); - notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); BroadcastReceiver br = new BroadcastReceiver() { @Override @@ -122,7 +121,7 @@ public class InstallManagerService extends Service { for (Map.Entry entry : ACTIVE_APKS.entrySet()) { if (TextUtils.equals(packageName, entry.getValue().packageName)) { String urlString = entry.getKey(); - cancelNotification(urlString); + NotificationHelper.removeApk(getApkFromActive(urlString)); break; } } @@ -150,7 +149,7 @@ public class InstallManagerService extends Service { Apk apk = getApkFromActive(urlString); DownloaderService.cancel(this, apk.getPatchObbUrl()); DownloaderService.cancel(this, apk.getMainObbUrl()); - cancelNotification(urlString); + NotificationHelper.removeApk(apk); return START_NOT_STICKY; } else if (!ACTION_INSTALL.equals(action)) { Utils.debugLog(TAG, "Ignoring " + intent + " as it is not an " + ACTION_INSTALL + " intent"); @@ -166,7 +165,7 @@ public class InstallManagerService extends Service { && !DownloaderService.isQueuedOrActive(urlString)) { // TODO is there a case where we should allow an active urlString to pass through? Utils.debugLog(TAG, urlString + " finished downloading while InstallManagerService was killed."); - cancelNotification(urlString); + NotificationHelper.removeApk(getApkFromActive(urlString)); return START_NOT_STICKY; } @@ -178,12 +177,9 @@ public class InstallManagerService extends Service { } addToActive(urlString, app, apk); - NotificationCompat.Builder builder = createNotificationBuilder(urlString, apk); - notificationManager.notify(urlString.hashCode(), builder.build()); - - registerApkDownloaderReceivers(urlString, builder); - getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256, builder); - getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256, builder); + registerApkDownloaderReceivers(urlString); + getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256); + getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256); File apkFilePath = ApkCache.getApkDownloadPath(this, intent.getData()); long apkFileSize = apkFilePath.length(); @@ -217,8 +213,7 @@ public class InstallManagerService extends Service { * @see APK Expansion Files */ private void getObb(final String urlString, String obbUrlString, - final File obbDestFile, final String sha256, - final NotificationCompat.Builder builder) { + final File obbDestFile, final String sha256) { if (obbDestFile == null || obbDestFile.exists() || TextUtils.isEmpty(obbUrlString)) { return; } @@ -232,8 +227,7 @@ public class InstallManagerService extends Service { int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); - builder.setProgress(totalBytes, bytesRead, false); - notificationManager.notify(urlString.hashCode(), builder.build()); + // TODO - handle obb notifications? } else if (Downloader.ACTION_COMPLETE.equals(action)) { localBroadcastManager.unregisterReceiver(this); File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); @@ -274,7 +268,7 @@ public class InstallManagerService extends Service { DownloaderService.getIntentFilter(obbUrlString)); } - private void registerApkDownloaderReceivers(String urlString, final NotificationCompat.Builder builder) { + private void registerApkDownloaderReceivers(String urlString) { BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override @@ -284,13 +278,17 @@ public class InstallManagerService extends Service { switch (intent.getAction()) { case Downloader.ACTION_STARTED: + Intent intentObject = new Intent(context, InstallManagerService.class); + intentObject.setAction(ACTION_CANCEL); + intentObject.setData(downloadUri); + PendingIntent action = PendingIntent.getService(context, 0, intentObject, 0); + NotificationHelper.setApk(getApkFromActive(urlString), NotificationHelper.Status.Downloading, action); // nothing to do break; case Downloader.ACTION_PROGRESS: int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); - builder.setProgress(totalBytes, bytesRead, false); - notificationManager.notify(urlString.hashCode(), builder.build()); + NotificationHelper.setApkProgress(getApkFromActive(urlString), totalBytes, bytesRead); break; case Downloader.ACTION_COMPLETE: File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); @@ -298,17 +296,19 @@ public class InstallManagerService extends Service { Utils.debugLog(TAG, "download completed of " + urlString + " to " + localApkUri); + NotificationHelper.setApk(getApkFromActive(urlString), NotificationHelper.Status.ReadyToInstall, null); + localBroadcastManager.unregisterReceiver(this); registerInstallerReceivers(downloadUri); Apk apk = ACTIVE_APKS.get(urlString); - InstallerService.install(context, localApkUri, downloadUri, apk); break; case Downloader.ACTION_INTERRUPTED: + NotificationHelper.removeApk(getApkFromActive(urlString)); + removeFromActive(urlString); localBroadcastManager.unregisterReceiver(this); - cancelNotification(urlString); break; default: throw new RuntimeException("intent action not handled!"); @@ -329,9 +329,10 @@ public class InstallManagerService extends Service { Apk apk; switch (intent.getAction()) { case Installer.ACTION_INSTALL_STARTED: - // nothing to do + NotificationHelper.setApk(getApkFromActive(downloadUrl), NotificationHelper.Status.Installing, null); break; case Installer.ACTION_INSTALL_COMPLETE: + NotificationHelper.setApk(getApkFromActive(downloadUrl), NotificationHelper.Status.Installed, null); Apk apkComplete = removeFromActive(downloadUrl); PackageManagerCompat.setInstaller(getPackageManager(), apkComplete.packageName); @@ -339,6 +340,8 @@ public class InstallManagerService extends Service { localBroadcastManager.unregisterReceiver(this); break; case Installer.ACTION_INSTALL_INTERRUPTED: + NotificationHelper.setApk(getApkFromActive(downloadUrl), NotificationHelper.Status.ReadyToInstall, null); + apk = intent.getParcelableExtra(Installer.EXTRA_APK); String errorMessage = intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); @@ -350,12 +353,7 @@ public class InstallManagerService extends Service { ContentResolver resolver = context.getContentResolver(); app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo); } - // show notification if app details is not visible - if (app != null && AppDetails.isAppVisible(app.packageName)) { - cancelNotification(downloadUrl); - } else { - notifyError(downloadUrl, app, errorMessage); - } + // TODO - show error } removeFromActive(downloadUrl); localBroadcastManager.unregisterReceiver(this); @@ -364,14 +362,7 @@ public class InstallManagerService extends Service { apk = intent.getParcelableExtra(Installer.EXTRA_APK); PendingIntent installPendingIntent = intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); - - // show notification if app details is not visible - if (AppDetails.isAppVisible(apk.packageName)) { - cancelNotification(downloadUrl); - } else { - notifyDownloadComplete(apk, downloadUrl, installPendingIntent); - } - + NotificationHelper.setApk(getApkFromActive(downloadUrl), NotificationHelper.Status.ReadyToInstall, installPendingIntent); break; default: throw new RuntimeException("intent action not handled!"); @@ -383,81 +374,10 @@ public class InstallManagerService extends Service { Installer.getInstallIntentFilter(downloadUri)); } - private NotificationCompat.Builder createNotificationBuilder(String urlString, Apk apk) { - int downloadUrlId = urlString.hashCode(); - return new NotificationCompat.Builder(this) - .setAutoCancel(false) - .setOngoing(true) - .setContentIntent(getAppDetailsIntent(downloadUrlId, apk)) - .setContentTitle(getString(R.string.downloading_apk, getAppName(apk))) - .addAction(R.drawable.ic_cancel_black_24dp, getString(R.string.cancel), - getCancelPendingIntent(urlString)) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentText(urlString) - .setProgress(100, 0, true); - } - private String getAppName(Apk apk) { return ACTIVE_APPS.get(apk.packageName).name; } - /** - * Get a {@link PendingIntent} for a {@link Notification} to send when it - * is clicked. {@link AppDetails} handles {@code Intent}s that are missing - * or bad {@link AppDetails#EXTRA_APPID}, so it does not need to be checked - * here. - */ - private PendingIntent getAppDetailsIntent(int requestCode, Apk apk) { - Intent notifyIntent = new Intent(getApplicationContext(), AppDetails.class) - .putExtra(AppDetails.EXTRA_APPID, apk.packageName); - return TaskStackBuilder.create(getApplicationContext()) - .addParentStack(AppDetails.class) - .addNextIntent(notifyIntent) - .getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT); - } - - /** - * Post a notification about a completed download. {@code packageName} must be a valid - * and currently in the app index database. This must create a new {@code Builder} - * instance otherwise the progress/cancel stuff does not go away. - * - * @see Issue 47809: - * Removing the progress bar from a notification should cause the notification's content - * text to return to normal size - */ - private void notifyDownloadComplete(Apk apk, String urlString, PendingIntent installPendingIntent) { - String title; - try { - PackageManager pm = getPackageManager(); - title = String.format(getString(R.string.tap_to_update_format), - pm.getApplicationLabel(pm.getApplicationInfo(apk.packageName, 0))); - } catch (PackageManager.NameNotFoundException e) { - String name = getAppName(apk); - if (TextUtils.isEmpty(name) || name.equals(new App().name)) { - ContentResolver resolver = getContentResolver(); - App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo, - new String[]{Schema.AppMetadataTable.Cols.NAME}); - if (app == null || TextUtils.isEmpty(app.name)) { - return; // do not have a name to display, so leave notification as is - } - name = app.name; - } - title = String.format(getString(R.string.tap_to_install_format), name); - } - - int downloadUrlId = urlString.hashCode(); - notificationManager.cancel(downloadUrlId); - Notification notification = new NotificationCompat.Builder(this) - .setAutoCancel(true) - .setOngoing(false) - .setContentTitle(title) - .setContentIntent(installPendingIntent) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentText(getString(R.string.tap_to_install)) - .build(); - notificationManager.notify(downloadUrlId, notification); - } - private void notifyError(String urlString, App app, String text) { int downloadUrlId = urlString.hashCode(); @@ -493,15 +413,6 @@ public class InstallManagerService extends Service { nm.notify(downloadUrlId, builder.build()); } - /** - * Cancel the {@link Notification} tied to {@code urlString}, which is the - * unique ID used to represent a given APK file. {@link String#hashCode()} - * converts {@code urlString} to the required {@code int}. - */ - private void cancelNotification(String urlString) { - notificationManager.cancel(urlString.hashCode()); - } - private static void addToActive(String urlString, App app, Apk apk) { ACTIVE_APKS.put(urlString, apk); ACTIVE_APPS.put(app.packageName, app); diff --git a/app/src/main/res/drawable/ic_notify_cancel_24dp.xml b/app/src/main/res/drawable/ic_notify_cancel_24dp.xml new file mode 100644 index 000000000..784cc0b1e --- /dev/null +++ b/app/src/main/res/drawable/ic_notify_cancel_24dp.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notify_install_24dp.xml b/app/src/main/res/drawable/ic_notify_install_24dp.xml new file mode 100644 index 000000000..644cb6070 --- /dev/null +++ b/app/src/main/res/drawable/ic_notify_install_24dp.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_notify_update_24dp.xml b/app/src/main/res/drawable/ic_notify_update_24dp.xml new file mode 100644 index 000000000..11b5def17 --- /dev/null +++ b/app/src/main/res/drawable/ic_notify_update_24dp.xml @@ -0,0 +1,4 @@ + + + +