diff --git a/app/build.gradle b/app/build.gradle
index d7bedaf25..2ee1df806 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -15,10 +15,13 @@ def getVersionName = { ->
repositories {
jcenter()
+ maven {
+ url "https://jitpack.io"
+ }
}
ext {
- supportLibVersion = '25.0.1'
+ supportLibVersion = '25.2.0'
}
dependencies {
@@ -41,12 +44,19 @@ dependencies {
compile 'commons-io:commons-io:2.5'
compile 'commons-net:commons-net:3.5'
compile 'org.openhab.jmdns:jmdns:3.4.2'
- compile('ch.acra:acra:4.9.1') {
- exclude module: 'support-v4'
- exclude module: 'support-annotations'
- }
+ compile 'ch.acra:acra:4.9.1'
compile 'io.reactivex:rxjava:1.1.0'
compile 'io.reactivex:rxandroid:0.23.0'
+ compile 'com.hannesdorfmann:adapterdelegates3:3.0.1'
+
+ // Migrate this to upstream https://github.com/Ashok-Varma/BottomNavigation if PR #110 gets
+ // accepted to drop the minSdk to 10.
+ compile('com.github.pserwylo:BottomNavigation:1.5.0') {
+ // These pull our explicit dependency on 25.2.0 up to 25.3.0 which is broken
+ // (https://code.google.com/p/android/issues/detail?id=251302)
+ exclude module: 'appcompat-v7'
+ exclude module: 'design'
+ }
testCompile 'junit:junit:4.12'
@@ -103,23 +113,25 @@ if (!hasProperty('sourceDeps')) {
'commons-net:commons-net:c25b0da668b3c5649f002d504def22d1b4cb30d206f05428d2fe168fa1a901c2',
'com.android.support.constraint:constraint-layout-solver:d03a406eb505dfa673b0087bf17e16d5a4d6bf8afdf452ee175e346207948cdf',
'com.android.support.constraint:constraint-layout:df1add69d11063eebba521818d63537b22207376b65f30cc35feea172b84e300',
- 'com.android.support:animated-vector-drawable:70443a2857f9968c4e2c27c107657ce2291d774f8a50f03444e12ab637451175',
- 'com.android.support:appcompat-v7:7fead560a22ea4b15848ce3000f312ef611fac0953bf90ca8710a72a1f6e36ea',
- 'com.android.support:cardview-v7:50d88fae8cd1076cb90504d36ca5ee9df4018555c8f041bd28f43274c0fc9e1f',
- 'com.android.support:design:07a72eb68c888b38d7b78e450e1af8a84e571406e0cf911889e0645d5a41f1e4',
- 'com.android.support:gridlayout-v7:cc11d2a3ee484e078c358a51d23a37e4bfbc542de410cacf275eafc5624bb888',
- 'com.android.support:palette-v7:89700afeedd988b471f0ce528ba916f368f549b47889b86b84d68eee42ea487c',
- 'com.android.support:recyclerview-v7:803baba7be537ace8c5cb8a775e37547c22a04c4b028833796c45c26ec1deca2',
- 'com.android.support:support-annotations:bd94ab42c841db16fb480f4c65d33d297e544655ecc498b37c5cf33a0c5f1968',
- 'com.android.support:support-compat:d04f15aa5f2ae9e8cb7d025bf02dfd4fd6f6800628ceb107e0589634c9e4e537',
- 'com.android.support:support-core-ui:29205ac978a1839d92be3d32db2385dac10f8688bba649e51650023c76de2f00',
- 'com.android.support:support-core-utils:632c3750bd991da8b591f24a8916e74ca6063ae7f525f005c96981725c9bf491',
- 'com.android.support:support-fragment:da47261a1d7c3d33e6e911335a7f4ce01135923bb221d3ab84625d005fa1969f',
- 'com.android.support:support-media-compat:01cac57af687bed9a6cb4ce803bebd1b7e6b8469c14f1f9ac6b4596637ff73d6',
- 'com.android.support:support-v4:50da261acc4ca3d2dea9a43106bf65488711ca97b20a4daa095dba381c205c98',
- 'com.android.support:support-vector-drawable:071ae3695bf8427d3cbfc8791492a3d9c804a4b111aa2a72fbfe7790ea268e5d',
- 'com.android.support:transition:9fd1e6d27cb70b3c5cd19f842b48bbb05cb4e5c93a22372769c342523393e8ea',
+ 'com.android.support:animated-vector-drawable:d2d59a11809abe3e64535346f05c22437b458de115f06ea35021fd0714960213',
+ 'com.android.support:appcompat-v7:120f3ce6cac682d69e53d80ccfa9cee076f0f11ccbe56d4ccd72099a745e81f9',
+ 'com.android.support:cardview-v7:c8610b0c334e4438d7e1ac58fcf2ac891fb26bac662c8351cd6b345c8d7b7076',
+ 'com.android.support:design:bf92337c5d0931df50a0dcec81682186dc1fbcf14c2fa1c6d51976963379b64d',
+ 'com.android.support:gridlayout-v7:257ac1280f2b3cc3c0afca1cd4d4d2e0b923b92a76b61a9c09fc57e892da7360',
+ 'com.android.support:palette-v7:e0050715e0d06fabcc8721b0c2893545fb00be9d761a6ef59ae69101d2368551',
+ 'com.android.support:recyclerview-v7:d6ba2c3a6196cc464eb4d69756229523a46eef7804991e5a8cf2d6306dbff10c',
+ 'com.android.support:support-annotations:47a2a30eab487a490a8a8f16678007c3d2b6dcae1e09b0485a12bbf921200ec3',
+ 'com.android.support:support-compat:5a7b6e18903458e3a561df24033476518f998cd7ae1ed747c2874e0685b999c7',
+ 'com.android.support:support-core-ui:cf3c75fd9a1b1dcbb6042d610515cd79cd0d65d3efd272d2250727187e8ca2ed',
+ 'com.android.support:support-core-utils:e0561cc9d00ae125d9e1ad8985d4639e68ce8399ae973e91674e97faaf658243',
+ 'com.android.support:support-fragment:f12633dd4d418a4edeb5ecf3bf4393edd0770b1eaa5d1ee3078c5e7c174868fd',
+ 'com.android.support:support-media-compat:e9f820d08e6a5735cfdb2a7d81d3c86b4a31897ac1edaeb55c7de06bcb370343',
+ 'com.android.support:support-v4:cd030f875dc7ee73b58e17598f368a2e12824fb3ceb4ed515ed815a47160228c',
+ 'com.android.support:support-vector-drawable:d79752fd68db5a8f5c18125517dafb9e4d7b593c755d188986010e15edd62454',
+ 'com.android.support:transition:5a4adefb1b410b23ad62b4477bc612edc47d3dfc8efed488deb8223b70b510d7',
+ 'com.github.pserwylo:BottomNavigation:83d7941a7a8d21ba1a8a708cd683b1bb07c6cf898044dc92eadf18a7a7d54f90',
'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259',
+ 'com.hannesdorfmann:adapterdelegates3:1b20d099d6e7afe57aceca13b713b386959d94a247c3c06a7aeb65b866ece02f',
'com.madgag.spongycastle:core:9b6b7ac856b91bcda2ede694eccd26cefb0bf0b09b89f13cda05b5da5ff68c6b',
'com.madgag.spongycastle:pkix:6aba9b2210907a3d46dd3dcac782bb3424185290468d102d5207ebdc9796a905',
'com.madgag.spongycastle:prov:029f26cd6b67c06ffa05702d426d472c141789001bcb15b7262ed86c868e5643',
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 402e44597..a9d100ba0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -297,8 +297,14 @@
+
-
+
diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java
index 8ca9fa56d..c6d48b7cb 100644
--- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java
+++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java
@@ -35,12 +35,47 @@ import java.util.Map;
*/
public final class AppUpdateStatusManager {
- static final String BROADCAST_APPSTATUS_LIST_CHANGED = "org.fdroid.fdroid.installer.appstatus.listchange";
- static final String BROADCAST_APPSTATUS_ADDED = "org.fdroid.fdroid.installer.appstatus.appchange.add";
- static final String BROADCAST_APPSTATUS_CHANGED = "org.fdroid.fdroid.installer.appstatus.appchange.change";
- static final String BROADCAST_APPSTATUS_REMOVED = "org.fdroid.fdroid.installer.appstatus.appchange.remove";
- static final String EXTRA_APK_URL = "urlstring";
- static final String EXTRA_IS_STATUS_UPDATE = "isstatusupdate";
+ /**
+ * 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_APK_URL = "urlstring";
+
+ 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";
+
+ /**
+ * 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";
@@ -147,9 +182,11 @@ public final class AppUpdateStatusManager {
notifyAdd(entry);
}
- private void notifyChange() {
+ private void notifyChange(String reason) {
if (!isBatchUpdating) {
- localBroadcastManager.sendBroadcast(new Intent(BROADCAST_APPSTATUS_LIST_CHANGED));
+ Intent intent = new Intent(BROADCAST_APPSTATUS_LIST_CHANGED);
+ intent.putExtra(EXTRA_REASON_FOR_CHANGE, reason);
+ localBroadcastManager.sendBroadcast(intent);
}
}
@@ -193,7 +230,7 @@ public final class AppUpdateStatusManager {
for (Apk apk : apksToUpdate) {
addApk(apk, status, null);
}
- endBatchUpdates();
+ endBatchUpdates(status);
}
/**
@@ -291,10 +328,17 @@ public final class AppUpdateStatusManager {
}
}
- private void endBatchUpdates() {
+ private void endBatchUpdates(Status status) {
synchronized (appMapping) {
isBatchUpdating = false;
- notifyChange();
+
+ String reason = null;
+ if (status == Status.ReadyToInstall) {
+ reason = REASON_READY_TO_INSTALL;
+ } else if (status == Status.UpdateAvailable) {
+ reason = REASON_UPDATES_AVAILABLE;
+ }
+ notifyChange(reason);
}
}
@@ -306,7 +350,7 @@ public final class AppUpdateStatusManager {
it.remove();
}
}
- notifyChange();
+ notifyChange(REASON_CLEAR_ALL_UPDATES);
}
}
@@ -318,7 +362,7 @@ public final class AppUpdateStatusManager {
it.remove();
}
}
- notifyChange();
+ notifyChange(REASON_CLEAR_ALL_INSTALLED);
}
}
diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java
new file mode 100644
index 000000000..f9b0e370c
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java
@@ -0,0 +1,57 @@
+package org.fdroid.fdroid;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+
+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 org.fdroid.fdroid.data.Schema;
+import org.fdroid.fdroid.installer.ApkCache;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Scans the list of downloaded .apk files in the cache for each app which can be updated.
+ * If a valid .apk file is found then it will tell the {@link AppUpdateStatusManager} that it is
+ * {@link AppUpdateStatusManager.Status#ReadyToInstall}. This is an {@link IntentService} so as to
+ * run on a background thread, as it hits the disk a bit to figure out the hash of each downloaded
+ * file.
+ *
+ * TODO: Deal with more than just the suggested version. It should also work for people downloading earlier versions (but still newer than their current)
+ * TODO: Identify new apps which have not been installed before, but which have been downloading. Currently only works for updates.
+ */
+public class AppUpdateStatusService extends IntentService {
+
+ /**
+ * Queue up a background scan of all downloaded apk files to see if we should notify the user
+ * that they are ready to install.
+ */
+ public static void scanDownloadedApks(Context context) {
+ context.startService(new Intent(context, AppUpdateStatusService.class));
+ }
+
+ public AppUpdateStatusService() {
+ super("AppUpdateStatusService");
+ }
+
+ @Override
+ protected void onHandleIntent(@Nullable Intent intent) {
+ List apps = AppProvider.Helper.findCanUpdate(this, Schema.AppMetadataTable.Cols.ALL);
+ List apksReadyToInstall = new ArrayList<>();
+ for (App app : apps) {
+ Apk apk = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode);
+ Uri downloadUri = Uri.parse(apk.getUrl());
+ if (ApkCache.apkIsCached(ApkCache.getApkDownloadPath(this, downloadUri), apk)) {
+ apksReadyToInstall.add(apk);
+ }
+ }
+
+ AppUpdateStatusManager.getInstance(this).addApks(apksReadyToInstall, AppUpdateStatusManager.Status.ReadyToInstall);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
index 41bad692f..49674fba3 100644
--- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
+++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java
@@ -234,6 +234,7 @@ public class FDroidApp extends Application {
Preferences.get().configureProxy();
InstalledAppProviderService.compareToPackageManager(this);
+ AppUpdateStatusService.scanDownloadedApks(this);
// If the user changes the preference to do with filtering rooted apps,
// it is easier to just notify a change in the app provider,
diff --git a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java
index 9a5e13215..258ef046b 100644
--- a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java
+++ b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java
@@ -410,7 +410,8 @@ class NotificationHelper {
// Intent to open main app list
Intent intentObject = new Intent(context, MainActivity.class);
- PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0);
+ intentObject.putExtra(MainActivity.EXTRA_VIEW_UPDATES, true);
+ PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context)
diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java
index 8d590540e..7e6fa22eb 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java
@@ -20,7 +20,10 @@ import android.support.v7.widget.RecyclerView;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewOutlineProvider;
+import android.widget.Button;
+import android.widget.ImageButton;
import android.widget.ImageView;
+import android.widget.ProgressBar;
import android.widget.TextView;
import com.nostra13.universalimageloader.core.DisplayImageOptions;
@@ -70,6 +73,18 @@ public class AppListItemController extends RecyclerView.ViewHolder {
@Nullable
private final TextView ignoredStatus;
+ @Nullable
+ private final ProgressBar progressBar;
+
+ @Nullable
+ private final ImageButton cancelButton;
+
+ /**
+ * Will operate as the "Download is complete, click to (install|update)" button.
+ */
+ @Nullable
+ private final Button actionButton;
+
private final DisplayImageOptions displayImageOptions;
private App currentApp;
@@ -107,6 +122,17 @@ public class AppListItemController extends RecyclerView.ViewHolder {
status = (TextView) itemView.findViewById(R.id.status);
installedVersion = (TextView) itemView.findViewById(R.id.installed_version);
ignoredStatus = (TextView) itemView.findViewById(R.id.ignored_status);
+ progressBar = (ProgressBar) itemView.findViewById(R.id.progress_bar);
+ cancelButton = (ImageButton) itemView.findViewById(R.id.cancel_button);
+ actionButton = (Button) itemView.findViewById(R.id.action_button);
+
+ if (actionButton != null) {
+ actionButton.setOnClickListener(onInstallClicked);
+ }
+
+ if (cancelButton != null) {
+ cancelButton.setOnClickListener(onCancelDownload);
+ }
displayImageOptions = Utils.getImageLoadingOptions().build();
@@ -115,7 +141,6 @@ public class AppListItemController extends RecyclerView.ViewHolder {
public void bindModel(@NonNull App app) {
currentApp = app;
- name.setText(Utils.formatAppNameAndSummary(app.name, app.summary));
ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions);
@@ -129,10 +154,12 @@ public class AppListItemController extends RecyclerView.ViewHolder {
broadcastManager.registerReceiver(onDownloadProgress, DownloaderService.getIntentFilter(currentAppDownloadUrl));
broadcastManager.registerReceiver(onInstallAction, Installer.getInstallIntentFilter(Uri.parse(currentAppDownloadUrl)));
+ configureAppName(app);
configureStatusText(app);
configureInstalledVersion(app);
configureIgnoredStatus(app);
configureInstallButton(app);
+ configureActionButton(app);
}
/**
@@ -140,8 +167,6 @@ public class AppListItemController extends RecyclerView.ViewHolder {
* * Is compatible with the users device
* * Is installed
* * Can be updated
- *
- * TODO: This button also needs to be repurposed to support the "Downloaded but not installed" state.
*/
private void configureStatusText(@NonNull App app) {
if (status == null) {
@@ -203,6 +228,10 @@ public class AppListItemController extends RecyclerView.ViewHolder {
}
}
+ /**
+ * Queries the {@link AppUpdateStatusManager} to find out if there are any apks corresponding to
+ * `app` which are ready to install.
+ */
private boolean isReadyToInstall(@NonNull App app) {
for (AppUpdateStatusManager.AppUpdateStatus appStatus : AppUpdateStatusManager.getInstance(activity).getByPackageName(app.packageName)) {
if (appStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) {
@@ -212,13 +241,74 @@ public class AppListItemController extends RecyclerView.ViewHolder {
return false;
}
+ /**
+ * Queries the {@link AppUpdateStatusManager} to find out if there are any apks corresponding to
+ * `app` which are in the process of being downloaded.
+ */
+ private boolean isDownloading(@NonNull App app) {
+ for (AppUpdateStatusManager.AppUpdateStatus appStatus : AppUpdateStatusManager.getInstance(activity).getByPackageName(app.packageName)) {
+ if (appStatus.status == AppUpdateStatusManager.Status.Downloading) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The app name {@link TextView} is used for a few reasons:
+ * * Display name + summary of the app (most common).
+ * * If downloading, mention that it is downloading instead of showing the summary.
+ * * If downloaded and ready to install, mention that it is ready to update/install.
+ */
+ private void configureAppName(@NonNull App app) {
+ if (isReadyToInstall(app)) {
+ if (app.isInstalled()) {
+ String appName = activity.getString(R.string.app_list__name__downloaded_and_ready_to_update, app.name);
+ if (app.lastUpdated != null) {
+ long ageInMillis = System.currentTimeMillis() - app.lastUpdated.getTime();
+ int ageInDays = (int) (ageInMillis / 1000 / 60 / 60 / 24);
+ String age = activity.getResources().getQuantityString(R.plurals.app_list__age__released_x_days_ago, ageInDays, ageInDays);
+ name.setText(appName + "\n" + age);
+ } else {
+ name.setText(appName);
+ }
+ } else {
+ name.setText(activity.getString(R.string.app_list__name__downloaded_and_ready_to_install, app.name));
+ }
+ } else if (isDownloading(app)) {
+ name.setText(activity.getString(R.string.app_list__name__downloading_in_progress, app.name));
+ } else {
+ name.setText(Utils.formatAppNameAndSummary(app.name, app.summary));
+ }
+ }
+
+ /**
+ * The action button will either tell the user to "Update" or "Install" the app. Both actually do
+ * the same thing (launch the package manager). It depends on whether the app has a previous
+ * version installed or not as to the chosen terminology.
+ */
+ private void configureActionButton(@NonNull App app) {
+ if (actionButton == null) {
+ return;
+ }
+
+ if (!isReadyToInstall(app)) {
+ actionButton.setVisibility(View.GONE);
+ } else {
+ actionButton.setVisibility(View.VISIBLE);
+ if (app.isInstalled()) {
+ actionButton.setText(R.string.app__install_downloaded_update);
+ } else {
+ actionButton.setText(R.string.menu_install);
+ }
+ }
+ }
+
/**
* The install button is shown when an app:
* * Is compatible with the users device.
* * Has not been filtered due to anti-features/root/etc.
* * Is either not installed or installed but can be updated.
- *
- * TODO: This button also needs to be repurposed to support the "Downloaded but not installed" state.
*/
private void configureInstallButton(@NonNull App app) {
if (installButton == null) {
@@ -242,6 +332,61 @@ public class AppListItemController extends RecyclerView.ViewHolder {
}
}
+ private void onDownloadStarted() {
+ if (installButton != null) {
+ installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
+ installButton.setImageLevel(0);
+ }
+
+ if (progressBar != null) {
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(true);
+ }
+
+ if (cancelButton != null) {
+ cancelButton.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void onDownloadProgressUpdated(int bytesRead, int totalBytes) {
+ if (installButton != null) {
+ installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
+ int progressAsDegrees = totalBytes <= 0 ? 0 : (int) (((float) bytesRead / totalBytes) * 360);
+ installButton.setImageLevel(progressAsDegrees);
+ }
+
+ if (progressBar != null) {
+ progressBar.setVisibility(View.VISIBLE);
+ if (totalBytes <= 0) {
+ progressBar.setIndeterminate(true);
+ } else {
+ progressBar.setIndeterminate(false);
+ progressBar.setMax(totalBytes);
+ progressBar.setProgress(bytesRead);
+ }
+ }
+
+ if (cancelButton != null) {
+ cancelButton.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void onDownloadComplete() {
+ if (installButton != null) {
+ installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_complete));
+ }
+
+ if (progressBar != null) {
+ progressBar.setVisibility(View.GONE);
+ }
+
+ if (cancelButton != null) {
+ cancelButton.setVisibility(View.GONE);
+ }
+
+ configureActionButton(currentApp);
+ }
+
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onAppClicked = new View.OnClickListener() {
@Override
@@ -262,22 +407,27 @@ public class AppListItemController extends RecyclerView.ViewHolder {
}
};
+ /**
+ * Updates both the progress bar and the circular install button (which shows progress around the outside of the circle).
+ * Also updates the app label to indicate that the app is being downloaded.
+ */
private final BroadcastReceiver onDownloadProgress = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
- if (installButton == null || currentApp == null || !TextUtils.equals(currentAppDownloadUrl, intent.getDataString())) {
+ if (currentApp == null || !TextUtils.equals(currentAppDownloadUrl, intent.getDataString()) || (installButton == null && progressBar == null)) {
return;
}
- if (Downloader.ACTION_PROGRESS.equals(intent.getAction())) {
- installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
- int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0);
- int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 100);
+ configureAppName(currentApp);
- int progressAsDegrees = (int) (((float) bytesRead / totalBytes) * 360);
- installButton.setImageLevel(progressAsDegrees);
+ if (Downloader.ACTION_STARTED.equals(intent.getAction())) {
+ onDownloadStarted();
+ } else if (Downloader.ACTION_PROGRESS.equals(intent.getAction())) {
+ int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0);
+ int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0);
+ onDownloadProgressUpdated(bytesRead, totalBytes);
} else if (Downloader.ACTION_COMPLETE.equals(intent.getAction())) {
- installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_complete));
+ onDownloadComplete();
}
}
};
@@ -289,6 +439,8 @@ public class AppListItemController extends RecyclerView.ViewHolder {
return;
}
+ configureAppName(currentApp);
+
Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
if (!TextUtils.equals(apk.packageName, currentApp.packageName)) {
return;
@@ -347,4 +499,16 @@ public class AppListItemController extends RecyclerView.ViewHolder {
}
}
};
+
+ @SuppressWarnings("FieldCanBeLocal")
+ private final View.OnClickListener onCancelDownload = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (currentAppDownloadUrl == null) {
+ return;
+ }
+
+ InstallManagerService.cancel(activity, currentAppDownloadUrl);
+ }
+ };
}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java
index d2c145f58..a864c483b 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java
@@ -119,7 +119,7 @@ public class CategorySpan extends ReplacementSpan {
canvas.drawRoundRect(iconBackgroundRect, cornerRadius, cornerRadius, iconBackgroundPaint);
// Category icon on top of the circular background which was just drawn.
- Drawable icon = ContextCompat.getDrawable(context, R.drawable.ic_category);
+ Drawable icon = ContextCompat.getDrawable(context, R.drawable.ic_categories);
icon.setBounds(iconPadding, iconPadding, iconPadding + iconSize, iconPadding + iconSize);
icon.draw(canvas);
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
index 8d62b8f9b..bd7711053 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainActivity.java
@@ -1,21 +1,31 @@
package org.fdroid.fdroid.views.main;
import android.app.SearchManager;
+import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.design.widget.BottomNavigationView;
+import android.support.annotation.Nullable;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.Loader;
+import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.text.TextUtils;
-import android.view.MenuItem;
import android.widget.Toast;
import android.support.v7.widget.RecyclerView;
+import com.ashokvarma.bottomnavigation.BadgeItem;
+import com.ashokvarma.bottomnavigation.BottomNavigationBar;
+import com.ashokvarma.bottomnavigation.BottomNavigationItem;
+
import org.fdroid.fdroid.AppDetails;
import org.fdroid.fdroid.AppDetails2;
+import org.fdroid.fdroid.AppUpdateStatusManager;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.NfcHelper;
import org.fdroid.fdroid.Preferences;
@@ -23,7 +33,9 @@ import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.compat.UriCompat;
+import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.NewRepoConfig;
+import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.views.ManageReposActivity;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
@@ -35,30 +47,38 @@ import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
* + Whats new
* + Categories list
* + App swap
- * + My apps
+ * + Updates
* + Settings
*
* Users navigate between items by using the bottom navigation bar, or by swiping left and right.
* When switching from one screen to the next, we stay within this activity. The new screen will
* get inflated (if required)
*/
-public class MainActivity extends AppCompatActivity implements BottomNavigationView.OnNavigationItemSelectedListener {
+public class MainActivity extends AppCompatActivity implements BottomNavigationBar.OnTabSelectedListener,
+ LoaderManager.LoaderCallbacks {
private static final String TAG = "MainActivity";
- public static final String EXTRA_VIEW_MY_APPS = "org.fdroid.fdroid.views.main.MainActivity.VIEW_MY_APPS";
+ public static final String EXTRA_VIEW_UPDATES = "org.fdroid.fdroid.views.main.MainActivity.VIEW_UPDATES";
private static final String ADD_REPO_INTENT_HANDLED = "addRepoIntentHandled";
private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.MainActivity.ACTION_ADD_REPO";
+ private static final String STATE_SELECTED_MENU_ID = "selectedMenuId";
+
+ private static final int LOADER_NUM_UPDATES = 1;
+
private static final int REQUEST_SWAP = 3;
private RecyclerView pager;
private MainViewAdapter adapter;
+ private BottomNavigationBar bottomNavigation;
+ private int selectedMenuId = R.id.whats_new;
+ private BadgeItem updatesBadge;
@Override
- protected void onCreate(Bundle savedInstanceState) {
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
@@ -70,8 +90,29 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
pager.setLayoutManager(new NonScrollingHorizontalLayoutManager(this));
pager.setAdapter(adapter);
- BottomNavigationView bottomNavigation = (BottomNavigationView) findViewById(R.id.bottom_navigation);
- bottomNavigation.setOnNavigationItemSelectedListener(this);
+ updatesBadge = new BadgeItem();
+
+ bottomNavigation = (BottomNavigationBar) findViewById(R.id.bottom_navigation);
+ bottomNavigation.setTabSelectedListener(this)
+ .setBarBackgroundColor(R.color.fdroid_blue)
+ .setInActiveColor(R.color.bottom_nav_items)
+ .setActiveColor(android.R.color.white)
+ .setMode(BottomNavigationBar.MODE_FIXED)
+ .addItem(new BottomNavigationItem(R.drawable.ic_latest, R.string.main_menu__latest_apps))
+ .addItem(new BottomNavigationItem(R.drawable.ic_categories, R.string.main_menu__categories))
+ .addItem(new BottomNavigationItem(R.drawable.ic_nearby, R.string.main_menu__swap_nearby))
+ .addItem(new BottomNavigationItem(R.drawable.ic_updates, R.string.updates).setBadgeItem(updatesBadge))
+ .addItem(new BottomNavigationItem(R.drawable.ic_settings, R.string.menu_settings))
+ .initialise();
+
+ IntentFilter updateableAppsFilter = new IntentFilter(AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED);
+ LocalBroadcastManager.getInstance(this).registerReceiver(onUpdateableAppsChanged, updateableAppsFilter);
+ getSupportLoaderManager().initLoader(LOADER_NUM_UPDATES, null, this);
+
+ if (savedInstanceState != null) {
+ selectedMenuId = savedInstanceState.getInt(STATE_SELECTED_MENU_ID, R.id.whats_new);
+ }
+ setSelectedMenuInNav();
initialRepoUpdateIfRequired();
@@ -79,6 +120,16 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
handleSearchOrAppViewIntent(intent);
}
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ outState.putInt(STATE_SELECTED_MENU_ID, selectedMenuId);
+ super.onSaveInstanceState(outState);
+ }
+
+ private void setSelectedMenuInNav() {
+ bottomNavigation.selectTab(adapter.adapterPositionFromItemId(selectedMenuId));
+ }
+
/**
* The first time the app is run, we will have an empty app list. To deal with this, we will
* attempt to update with the default repo. However, if we have tried this at least once, then
@@ -99,9 +150,11 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
FDroidApp.checkStartTor(this);
- if (getIntent().hasExtra(EXTRA_VIEW_MY_APPS)) {
- getIntent().removeExtra(EXTRA_VIEW_MY_APPS);
- pager.scrollToPosition(adapter.adapterPositionFromItemId(R.id.my_apps));
+ if (getIntent().hasExtra(EXTRA_VIEW_UPDATES)) {
+ getIntent().removeExtra(EXTRA_VIEW_UPDATES);
+ pager.scrollToPosition(adapter.adapterPositionFromItemId(R.id.updates));
+ selectedMenuId = R.id.updates;
+ setSelectedMenuInNav();
}
// AppDetails 2 and RepoDetailsActivity set different NFC actions, so reset here
@@ -127,9 +180,19 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
}
@Override
- public boolean onNavigationItemSelected(@NonNull MenuItem item) {
- pager.scrollToPosition(((MainViewAdapter) pager.getAdapter()).adapterPositionFromItemId(item.getItemId()));
- return true;
+ public void onTabSelected(int position) {
+ pager.scrollToPosition(position);
+ selectedMenuId = (int) adapter.getItemId(position);
+ }
+
+ @Override
+ public void onTabUnselected(int position) {
+
+ }
+
+ @Override
+ public void onTabReselected(int position) {
+
}
private void handleSearchOrAppViewIntent(Intent intent) {
@@ -257,6 +320,36 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
}
}
+ @Override
+ public Loader onCreateLoader(int id, Bundle args) {
+ Uri uri = AppProvider.getCanUpdateUri();
+ String[] projection = new String[]{Schema.AppMetadataTable.Cols._COUNT};
+
+ return new CursorLoader(this, uri, projection, null, null, null);
+ }
+
+ @Override
+ public void onLoadFinished(Loader loader, Cursor cursor) {
+ cursor.moveToFirst();
+ int canUpdateCount = cursor.getInt(cursor.getColumnIndex(Schema.AppMetadataTable.Cols._COUNT));
+ cursor.close();
+ refreshUpdatesBadge(canUpdateCount);
+ }
+
+ private void refreshUpdatesBadge(int canUpdateCount) {
+ if (canUpdateCount == 0) {
+ updatesBadge.hide(true);
+ } else {
+ updatesBadge.setText(Integer.toString(canUpdateCount));
+ updatesBadge.show(true);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader loader) {
+
+ }
+
private static class NonScrollingHorizontalLayoutManager extends LinearLayoutManager {
NonScrollingHorizontalLayoutManager(Context context) {
super(context, LinearLayoutManager.HORIZONTAL, false);
@@ -273,4 +366,13 @@ public class MainActivity extends AppCompatActivity implements BottomNavigationV
}
}
+ private final BroadcastReceiver onUpdateableAppsChanged = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (AppUpdateStatusManager.REASON_UPDATES_AVAILABLE.equals(intent.getStringExtra(AppUpdateStatusManager.EXTRA_REASON_FOR_CHANGE))) {
+ getSupportLoaderManager().restartLoader(LOADER_NUM_UPDATES, null, MainActivity.this);
+ }
+ }
+ };
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java
index 7f3762204..7731c89c3 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewAdapter.java
@@ -13,7 +13,7 @@ import org.fdroid.fdroid.R;
* + Whats new
* + Categories
* + Nearby
- * + My Apps
+ * + Updates
* + Settings
*
* It is responsible for understanding the relationship between each main view that is reachable
@@ -35,10 +35,26 @@ class MainViewAdapter extends RecyclerView.Adapter {
positionToId.put(0, R.id.whats_new);
positionToId.put(1, R.id.categories);
positionToId.put(2, R.id.nearby);
- positionToId.put(3, R.id.my_apps);
+ positionToId.put(3, R.id.updates);
positionToId.put(4, R.id.settings);
}
+ @Override
+ public void onViewDetachedFromWindow(MainViewController holder) {
+ long viewType = getItemId(holder.getAdapterPosition());
+ if (viewType == R.id.updates) {
+ holder.unbindUpdates();
+ }
+ }
+
+ @Override
+ public void onViewAttachedToWindow(MainViewController holder) {
+ long viewType = getItemId(holder.getAdapterPosition());
+ if (viewType == R.id.updates) {
+ holder.bindUpdates();
+ }
+ }
+
@Override
public MainViewController onCreateViewHolder(ViewGroup parent, int viewType) {
MainViewController holder = createEmptyView();
@@ -52,8 +68,8 @@ class MainViewAdapter extends RecyclerView.Adapter {
case R.id.nearby:
holder.bindSwapView();
break;
- case R.id.my_apps:
- holder.bindMyApps();
+ case R.id.updates:
+ holder.bindUpdates();
break;
case R.id.settings:
holder.bindSettingsView();
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
index cecced816..e5e804c3b 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
@@ -1,6 +1,7 @@
package org.fdroid.fdroid.views.main;
import android.content.Intent;
+import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.RecyclerView;
@@ -10,7 +11,7 @@ import android.widget.FrameLayout;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.views.fragments.PreferencesFragment;
-import org.fdroid.fdroid.views.myapps.MyAppsViewBinder;
+import org.fdroid.fdroid.views.updates.UpdatesViewBinder;
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
/**
@@ -24,6 +25,9 @@ class MainViewController extends RecyclerView.ViewHolder {
private final AppCompatActivity activity;
private final FrameLayout frame;
+ @Nullable
+ private UpdatesViewBinder updatesView = null;
+
MainViewController(AppCompatActivity activity, FrameLayout frame) {
super(frame);
this.activity = activity;
@@ -38,10 +42,20 @@ class MainViewController extends RecyclerView.ViewHolder {
}
/**
- * @see MyAppsViewBinder
+ * @see UpdatesViewBinder
*/
- public void bindMyApps() {
- new MyAppsViewBinder(activity, frame);
+ public void bindUpdates() {
+ if (updatesView == null) {
+ updatesView = new UpdatesViewBinder(activity, frame);
+ }
+
+ updatesView.bind();
+ }
+
+ public void unbindUpdates() {
+ if (updatesView != null) {
+ updatesView.unbind();
+ }
}
/**
diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java
deleted file mode 100644
index c8adba2ce..000000000
--- a/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.fdroid.fdroid.views.myapps;
-
-import android.support.v7.widget.RecyclerView;
-import android.view.View;
-
-public class InstalledHeaderController extends RecyclerView.ViewHolder {
- public InstalledHeaderController(View itemView) {
- super(itemView);
- }
-}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java
deleted file mode 100644
index abfdfb7e6..000000000
--- a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package org.fdroid.fdroid.views.myapps;
-
-import android.app.Activity;
-import android.database.Cursor;
-import android.support.v7.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.ViewGroup;
-
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.data.App;
-import org.fdroid.fdroid.views.apps.AppListItemController;
-import org.fdroid.fdroid.views.apps.AppListItemDivider;
-
-/**
- * Wraps a cursor which should have a list of "apps which can be updated". Also includes a header
- * as the first element which allows for all items to be updated.
- */
-public class MyAppsAdapter extends RecyclerView.Adapter {
-
- private Cursor updatesCursor;
- private final Activity activity;
- private final AppListItemDivider divider;
-
- public MyAppsAdapter(Activity activity) {
- this.activity = activity;
- divider = new AppListItemDivider(activity);
- }
-
- @Override
- public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- LayoutInflater inflater = activity.getLayoutInflater();
- switch (viewType) {
- case R.id.my_apps__header:
- return new UpdatesHeaderController(activity, inflater.inflate(R.layout.my_apps_updates_header, parent, false));
-
- case R.id.my_apps__app:
- return new AppListItemController(activity, inflater.inflate(R.layout.app_list_item, parent, false));
-
- default:
- throw new IllegalArgumentException();
- }
- }
-
- @Override
- public int getItemCount() {
- return updatesCursor == null ? 0 : updatesCursor.getCount() + 1;
- }
-
- @Override
- public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
- switch (getItemViewType(position)) {
- case R.id.my_apps__header:
- ((UpdatesHeaderController) holder).bindModel(updatesCursor.getCount());
- break;
-
- case R.id.my_apps__app:
- updatesCursor.moveToPosition(position - 1); // Subtract one to account for the header.
- ((AppListItemController) holder).bindModel(new App(updatesCursor));
- break;
-
- default:
- throw new IllegalArgumentException();
- }
- }
-
- @Override
- public int getItemViewType(int position) {
- if (position == 0) {
- return R.id.my_apps__header;
- } else {
- return R.id.my_apps__app;
- }
- }
-
- public void setApps(Cursor cursor) {
- updatesCursor = cursor;
- notifyDataSetChanged();
- }
-
- @Override
- public void onAttachedToRecyclerView(RecyclerView recyclerView) {
- super.onAttachedToRecyclerView(recyclerView);
- recyclerView.addItemDecoration(divider);
- }
-
- @Override
- public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
- recyclerView.removeItemDecoration(divider);
- super.onDetachedFromRecyclerView(recyclerView);
- }
-
-}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java
deleted file mode 100644
index 8e3ec2c73..000000000
--- a/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.fdroid.fdroid.views.myapps;
-
-import android.app.Activity;
-import android.database.Cursor;
-import android.os.Bundle;
-import android.support.v4.app.LoaderManager;
-import android.support.v4.content.CursorLoader;
-import android.support.v4.content.Loader;
-import android.support.v7.app.AppCompatActivity;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.view.View;
-import android.widget.FrameLayout;
-
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.data.AppProvider;
-import org.fdroid.fdroid.data.Schema;
-
-public class MyAppsViewBinder implements LoaderManager.LoaderCallbacks {
-
- private final MyAppsAdapter adapter;
-
- private final Activity activity;
-
- public MyAppsViewBinder(AppCompatActivity activity, FrameLayout parent) {
- this.activity = activity;
-
- View myAppsView = activity.getLayoutInflater().inflate(R.layout.main_tabs, parent, true);
-
- adapter = new MyAppsAdapter(activity);
-
- RecyclerView list = (RecyclerView) myAppsView.findViewById(R.id.list);
- list.setHasFixedSize(true);
- list.setLayoutManager(new LinearLayoutManager(activity));
- list.setAdapter(adapter);
-
- LoaderManager loaderManager = activity.getSupportLoaderManager();
- loaderManager.initLoader(0, null, this);
- }
-
- @Override
- public Loader onCreateLoader(int id, Bundle args) {
- return new CursorLoader(
- activity,
- AppProvider.getCanUpdateUri(),
- new String[]{
- Schema.AppMetadataTable.Cols._ID, // Required for cursor loader to work.
- Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME,
- Schema.AppMetadataTable.Cols.NAME,
- Schema.AppMetadataTable.Cols.SUMMARY,
- Schema.AppMetadataTable.Cols.IS_COMPATIBLE,
- Schema.AppMetadataTable.Cols.LICENSE,
- Schema.AppMetadataTable.Cols.ICON,
- Schema.AppMetadataTable.Cols.ICON_URL,
- Schema.AppMetadataTable.Cols.InstalledApp.VERSION_CODE,
- Schema.AppMetadataTable.Cols.InstalledApp.VERSION_NAME,
- Schema.AppMetadataTable.Cols.SuggestedApk.VERSION_NAME,
- Schema.AppMetadataTable.Cols.SUGGESTED_VERSION_CODE,
- Schema.AppMetadataTable.Cols.REQUIREMENTS, // Needed for filtering apps that require root.
- Schema.AppMetadataTable.Cols.ANTI_FEATURES, // Needed for filtering apps that require anti-features.
- },
- null,
- null,
- null
- );
- }
-
- @Override
- public void onLoadFinished(Loader loader, Cursor cursor) {
- adapter.setApps(cursor);
- }
-
- @Override
- public void onLoaderReset(Loader loader) {
- adapter.setApps(null);
- }
-}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java b/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java
deleted file mode 100644
index 1bbf61210..000000000
--- a/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.fdroid.fdroid.views.myapps;
-
-import android.app.Activity;
-import android.support.v7.widget.RecyclerView;
-import android.view.View;
-import android.widget.Button;
-import android.widget.TextView;
-
-import org.fdroid.fdroid.R;
-import org.fdroid.fdroid.UpdateService;
-
-public class UpdatesHeaderController extends RecyclerView.ViewHolder {
-
- private final Activity activity;
- private final TextView updatesHeading;
-
- public UpdatesHeaderController(Activity activity, View itemView) {
- super(itemView);
- this.activity = activity;
-
- Button updateAll = (Button) itemView.findViewById(R.id.update_all_button);
- updateAll.setOnClickListener(onUpdateAll);
-
- updatesHeading = (TextView) itemView.findViewById(R.id.updates_heading);
- updatesHeading.setText(activity.getString(R.string.updates));
- }
-
- public void bindModel(int numAppsToUpdate) {
- updatesHeading.setText(activity.getResources().getQuantityString(R.plurals.my_apps_header_number_of_updateable, numAppsToUpdate, numAppsToUpdate));
- }
-
- private final View.OnClickListener onUpdateAll = new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- UpdateService.autoDownloadUpdates(activity);
- }
- };
-}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java
new file mode 100644
index 000000000..b144b957f
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java
@@ -0,0 +1,365 @@
+package org.fdroid.fdroid.views.updates;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.Loader;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.text.TextUtils;
+import android.view.ViewGroup;
+
+import com.hannesdorfmann.adapterdelegates3.AdapterDelegatesManager;
+
+import org.fdroid.fdroid.AppUpdateStatusManager;
+import org.fdroid.fdroid.data.App;
+import org.fdroid.fdroid.data.AppProvider;
+import org.fdroid.fdroid.data.Schema;
+import org.fdroid.fdroid.views.updates.items.AppNotification;
+import org.fdroid.fdroid.views.updates.items.AppStatus;
+import org.fdroid.fdroid.views.updates.items.AppUpdateData;
+import org.fdroid.fdroid.views.updates.items.DonationPrompt;
+import org.fdroid.fdroid.views.updates.items.UpdateableApp;
+import org.fdroid.fdroid.views.updates.items.UpdateableAppsHeader;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Manages the following types of information:
+ * * Apps marked for downloading (while the user is offline)
+ * * Currently downloading apps
+ * * Apps which have been downloaded (and need further action to install). This includes new installs and updates.
+ * * Reminders to users that they can donate to apps (only shown infrequently after several updates)
+ * * A list of apps which are eligible to be updated (for when the "Automatic Updates" option is disabled), including:
+ * + A summary of all apps to update including an "Update all" button and a "Show apps" button.
+ * + Once "Show apps" is expanded then each app is shown along with its own download button.
+ *
+ * It does this by maintaining several different lists of interesting apps. Each list contains wrappers
+ * around the piece of data it wants to render ({@link AppStatus}, {@link DonationPrompt},
+ * {@link AppNotification}, {@link UpdateableApp}). Instead of juggling the various viewTypes
+ * to find out which position in the adapter corresponds to which view type, this is handled by
+ * the {@link UpdatesAdapter#delegatesManager}.
+ *
+ * There are a series of type-safe lists which hold the specific data this adapter is interested in.
+ * This data is then collated into a single list (see {@link UpdatesAdapter#populateItems()}) which
+ * is the actual thing the adapter binds too. At any point it is safe to clear the single list and
+ * repopulate it from the original source lists of data. When this is done, the adapter will notify
+ * the recycler view that its data has changed. Sometimes it will also ask the recycler view to
+ * scroll to the newly added item (if attached to the recycler view).
+ */
+public class UpdatesAdapter extends RecyclerView.Adapter implements LoaderManager.LoaderCallbacks {
+
+ private final AdapterDelegatesManager> delegatesManager = new AdapterDelegatesManager<>();
+ private final List items = new ArrayList<>();
+
+ private final AppCompatActivity activity;
+
+ @Nullable
+ private RecyclerView recyclerView;
+
+ private final List appsToShowStatus = new ArrayList<>();
+ private final List appsToPromptForDonation = new ArrayList<>();
+ private final List appsToNotifyAbout = new ArrayList<>();
+ private final List updateableApps = new ArrayList<>();
+
+ private boolean showAllUpdateableApps = false;
+
+ public UpdatesAdapter(AppCompatActivity activity) {
+ this.activity = activity;
+
+ delegatesManager.addDelegate(new AppStatus.Delegate(activity))
+ .addDelegate(new AppNotification.Delegate())
+ .addDelegate(new DonationPrompt.Delegate())
+ .addDelegate(new UpdateableApp.Delegate(activity))
+ .addDelegate(new UpdateableAppsHeader.Delegate(activity));
+
+ populateAppStatuses();
+ notifyDataSetChanged();
+
+ activity.getSupportLoaderManager().initLoader(0, null, this);
+ }
+
+ /**
+ * There are some statuses managed by {@link AppUpdateStatusManager} which we don't care about
+ * for the "Updates" view. For example {@link org.fdroid.fdroid.AppUpdateStatusManager.Status#Installed}
+ * apps are not interesting in the Updates" view at this point in time. Also, although this
+ * adapter does know about apps with updates availble, it does so by querying the database not
+ * by querying the app update status manager. As such, apps with the status
+ * {@link org.fdroid.fdroid.AppUpdateStatusManager.Status#UpdateAvailable} are not interesting here.
+ */
+ private boolean shouldShowStatus(AppUpdateStatusManager.AppUpdateStatus status) {
+ return status.status == AppUpdateStatusManager.Status.Unknown ||
+ status.status == AppUpdateStatusManager.Status.Downloading ||
+ status.status == AppUpdateStatusManager.Status.ReadyToInstall;
+ }
+
+ /**
+ * Adds items from the {@link AppUpdateStatusManager} to {@link UpdatesAdapter#appsToShowStatus}.
+ * Note that this will then subsequently rebuild the underlying adapter data structure by
+ * invoking {@link UpdatesAdapter#populateItems}. However as per the populateItems method, it
+ * does not know how best to notify the recycler view of any changes. That is up to the caller
+ * of this method.
+ */
+ private void populateAppStatuses() {
+ for (AppUpdateStatusManager.AppUpdateStatus status : AppUpdateStatusManager.getInstance(activity).getAll()) {
+ if (shouldShowStatus(status)) {
+ appsToShowStatus.add(new AppStatus(activity, status));
+ }
+ }
+
+ Collections.sort(appsToShowStatus, new Comparator() {
+ @Override
+ public int compare(AppStatus o1, AppStatus o2) {
+ return o1.status.app.name.compareTo(o2.status.app.name);
+ }
+ });
+
+ populateItems();
+ }
+
+ public boolean canViewAllUpdateableApps() {
+ return showAllUpdateableApps;
+ }
+
+ public void toggleAllUpdateableApps() {
+ showAllUpdateableApps = !showAllUpdateableApps;
+ populateItems();
+
+ if (showAllUpdateableApps) {
+ notifyItemRangeInserted(appsToShowStatus.size() + 1, updateableApps.size());
+ if (recyclerView != null) {
+ // Scroll so that the "Update X apps" header is at the top of the page, and the
+ // list of apps takes up the rest of the screen.
+ ((LinearLayoutManager) recyclerView.getLayoutManager()).scrollToPositionWithOffset(appsToShowStatus.size(), 0);
+ }
+ } else {
+ notifyItemRangeRemoved(appsToShowStatus.size() + 1, updateableApps.size());
+ }
+ }
+
+ /**
+ * Completely rebuilds the underlying data structure used by this adapter. Note however, that
+ * this does not notify the recycler view of any changes. Thus, it is up to other methods which
+ * initiate a call to this method to make sure they appropriately notify the recyler view.
+ */
+ private void populateItems() {
+ items.clear();
+
+ items.addAll(appsToShowStatus);
+
+ if (updateableApps != null && updateableApps.size() > 0) {
+ items.add(new UpdateableAppsHeader(activity, this, updateableApps));
+ if (showAllUpdateableApps) {
+ items.addAll(updateableApps);
+ }
+ }
+
+ items.addAll(appsToPromptForDonation);
+ items.addAll(appsToNotifyAbout);
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return delegatesManager.getItemViewType(items, position);
+ }
+
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ return delegatesManager.onCreateViewHolder(parent, viewType);
+ }
+
+ @Override
+ public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
+ delegatesManager.onBindViewHolder(items, position, holder);
+ }
+
+ @Override
+ public void onAttachedToRecyclerView(RecyclerView recyclerView) {
+ super.onAttachedToRecyclerView(recyclerView);
+ this.recyclerView = recyclerView;
+ }
+
+ @Override
+ public Loader onCreateLoader(int id, Bundle args) {
+ return new CursorLoader(
+ activity,
+ AppProvider.getCanUpdateUri(),
+ new String[]{
+ Schema.AppMetadataTable.Cols._ID, // Required for cursor loader to work.
+ Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME,
+ Schema.AppMetadataTable.Cols.NAME,
+ Schema.AppMetadataTable.Cols.SUMMARY,
+ Schema.AppMetadataTable.Cols.IS_COMPATIBLE,
+ Schema.AppMetadataTable.Cols.LICENSE,
+ Schema.AppMetadataTable.Cols.ICON,
+ Schema.AppMetadataTable.Cols.ICON_URL,
+ Schema.AppMetadataTable.Cols.InstalledApp.VERSION_CODE,
+ Schema.AppMetadataTable.Cols.InstalledApp.VERSION_NAME,
+ Schema.AppMetadataTable.Cols.SuggestedApk.VERSION_NAME,
+ Schema.AppMetadataTable.Cols.SUGGESTED_VERSION_CODE,
+ Schema.AppMetadataTable.Cols.REQUIREMENTS, // Needed for filtering apps that require root.
+ Schema.AppMetadataTable.Cols.ANTI_FEATURES, // Needed for filtering apps that require anti-features.
+ },
+ null,
+ null,
+ Schema.AppMetadataTable.Cols.NAME
+ );
+ }
+
+ @Override
+ public void onLoadFinished(Loader loader, Cursor cursor) {
+ int numberRemoved = updateableApps.size();
+ boolean hadHeader = updateableApps.size() > 0;
+ boolean willHaveHeader = cursor.getCount() > 0;
+
+ updateableApps.clear();
+ notifyItemRangeRemoved(appsToShowStatus.size(), numberRemoved + (hadHeader ? 1 : 0));
+
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ updateableApps.add(new UpdateableApp(activity, new App(cursor)));
+ cursor.moveToNext();
+ }
+
+ populateItems();
+ notifyItemRangeInserted(appsToShowStatus.size(), updateableApps.size() + (willHaveHeader ? 1 : 0));
+ }
+
+ @Override
+ public void onLoaderReset(Loader loader) { }
+
+ /**
+ * Doesn't listen for {@link AppUpdateStatusManager#BROADCAST_APPSTATUS_CHANGED} because the
+ * individual items in the recycler view will listen for the appropriate changes in state and
+ * update themselves accordingly (if they are displayed).
+ */
+ public void listenForStatusUpdates() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED);
+ filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED);
+ filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED);
+
+ LocalBroadcastManager.getInstance(activity).registerReceiver(receiverAppStatusChanges, filter);
+ }
+
+ public void stopListeningForStatusUpdates() {
+ LocalBroadcastManager.getInstance(activity).unregisterReceiver(receiverAppStatusChanges);
+ }
+
+ private void onManyAppStatusesChanged(String reasonForChange) {
+ switch (reasonForChange) {
+ case AppUpdateStatusManager.REASON_UPDATES_AVAILABLE:
+ onUpdateableAppsChanged();
+ break;
+
+ case AppUpdateStatusManager.REASON_READY_TO_INSTALL:
+ onFoundAppsReadyToInstall();
+ break;
+ }
+ }
+
+ /**
+ * Apps have been made available for update which were not available for update before.
+ * We need to rerun our database query to get a list of apps to update.
+ */
+ private void onUpdateableAppsChanged() {
+ activity.getSupportLoaderManager().initLoader(0, null, this);
+ }
+
+ /**
+ * We have completed a scan of .apk files in the cache, and identified there are
+ * some which are ready to install.
+ */
+ private void onFoundAppsReadyToInstall() {
+ if (appsToShowStatus.size() > 0) {
+ int size = appsToShowStatus.size();
+ appsToShowStatus.clear();
+ notifyItemRangeRemoved(0, size);
+ }
+
+ populateAppStatuses();
+ notifyItemRangeInserted(0, appsToShowStatus.size());
+
+ if (recyclerView != null) {
+ recyclerView.smoothScrollToPosition(0);
+ }
+ }
+
+ private void onAppStatusAdded(String apkUrl) {
+ // We could try and find the specific place where we need to add our new item, but it is
+ // far simpler to clear the list and rebuild it (sorting it in the process).
+ appsToShowStatus.clear();
+ populateAppStatuses();
+
+ // After adding the new item to our list (somewhere) we can then look it back up again in
+ // order to notify the recycler view and scroll to that item.
+ int positionOfNewApp = 0;
+ for (int i = 0; i < appsToShowStatus.size(); i++) {
+ if (TextUtils.equals(appsToShowStatus.get(i).status.getUniqueKey(), apkUrl)) {
+ positionOfNewApp = i;
+ break;
+ }
+ }
+
+ notifyItemInserted(positionOfNewApp);
+
+ if (recyclerView != null) {
+ recyclerView.smoothScrollToPosition(positionOfNewApp);
+ }
+ }
+
+ private void onAppStatusRemoved(String apkUrl) {
+ // Find out where the item is in our internal data structure, so that we can remove it and
+ // also notify the recycler view appropriately.
+ int positionOfOldApp = 0;
+ for (int i = 0; i < appsToShowStatus.size(); i++) {
+ if (TextUtils.equals(appsToShowStatus.get(i).status.getUniqueKey(), apkUrl)) {
+ positionOfOldApp = i;
+ break;
+ }
+ }
+
+ appsToShowStatus.remove(positionOfOldApp);
+
+ populateItems();
+ notifyItemRemoved(positionOfOldApp);
+ }
+
+ private final BroadcastReceiver receiverAppStatusChanges = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String apkUrl = intent.getStringExtra(AppUpdateStatusManager.EXTRA_APK_URL);
+
+ switch (intent.getAction()) {
+ case AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED:
+ onManyAppStatusesChanged(intent.getStringExtra(AppUpdateStatusManager.EXTRA_REASON_FOR_CHANGE));
+ break;
+
+ case AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED:
+ onAppStatusAdded(apkUrl);
+ break;
+
+ case AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED:
+ onAppStatusRemoved(apkUrl);
+ break;
+ }
+ }
+ };
+
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesViewBinder.java
new file mode 100644
index 000000000..c48f802dd
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesViewBinder.java
@@ -0,0 +1,33 @@
+package org.fdroid.fdroid.views.updates;
+
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import org.fdroid.fdroid.R;
+
+public class UpdatesViewBinder {
+
+ private final UpdatesAdapter adapter;
+
+ public UpdatesViewBinder(AppCompatActivity activity, FrameLayout parent) {
+ View view = activity.getLayoutInflater().inflate(R.layout.main_tab_updates, parent, true);
+
+ adapter = new UpdatesAdapter(activity);
+
+ RecyclerView list = (RecyclerView) view.findViewById(R.id.list);
+ list.setHasFixedSize(true);
+ list.setLayoutManager(new LinearLayoutManager(activity));
+ list.setAdapter(adapter);
+ }
+
+ public void bind() {
+ adapter.listenForStatusUpdates();
+ }
+
+ public void unbind() {
+ adapter.stopListeningForStatusUpdates();
+ }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppNotification.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppNotification.java
new file mode 100644
index 000000000..dadf209e5
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppNotification.java
@@ -0,0 +1,56 @@
+package org.fdroid.fdroid.views.updates.items;
+
+import android.app.Activity;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.hannesdorfmann.adapterdelegates3.AdapterDelegate;
+
+import java.util.List;
+
+/**
+ * Each of these apps has a notification to display to the user.
+ * The notification will have come from the apps metadata, provided by its maintainer. It may be
+ * something about the app being removed from the repository, or perhaps security problems that
+ * were identified in the app.
+ */
+public class AppNotification extends AppUpdateData {
+
+ public AppNotification(Activity activity) {
+ super(activity);
+ }
+
+ public static class Delegate extends AdapterDelegate> {
+
+ @Override
+ protected boolean isForViewType(@NonNull List items, int position) {
+ return items.get(position) instanceof AppNotification;
+ }
+
+ @NonNull
+ @Override
+ protected RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent) {
+ return new ViewHolder(new TextView(parent.getContext()));
+ }
+
+ @Override
+ protected void onBindViewHolder(@NonNull List items, int position, @NonNull RecyclerView.ViewHolder holder, @NonNull List