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 payloads) { + AppNotification app = (AppNotification) items.get(position); + ((ViewHolder) holder).bindApp(app); + } + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + public void bindApp(AppNotification app) { + ((TextView) itemView).setText("Notification for app"); + } + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppStatus.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppStatus.java new file mode 100644 index 000000000..22ae812dc --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppStatus.java @@ -0,0 +1,57 @@ +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.ViewGroup; + +import com.hannesdorfmann.adapterdelegates3.AdapterDelegate; + +import org.fdroid.fdroid.AppUpdateStatusManager; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.views.apps.AppListItemController; + +import java.util.List; + +/** + * Apps which we want to show some more substantial information about. + * @see R.layout#updateable_app_status_item The view that this binds to + * @see AppListItemController Used for binding the {@link App} to the {@link R.layout#updateable_app_status_item} + */ +public class AppStatus extends AppUpdateData { + + public final AppUpdateStatusManager.AppUpdateStatus status; + + public AppStatus(Activity activity, AppUpdateStatusManager.AppUpdateStatus status) { + super(activity); + this.status = status; + } + + public static class Delegate extends AdapterDelegate> { + + private final Activity activity; + + public Delegate(Activity activity) { + this.activity = activity; + } + + @Override + protected boolean isForViewType(@NonNull List items, int position) { + return items.get(position) instanceof AppStatus; + } + + @NonNull + @Override + protected RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent) { + return new AppListItemController(activity, activity.getLayoutInflater().inflate(R.layout.updateable_app_status_item, parent, false)); + } + + @Override + protected void onBindViewHolder(@NonNull List items, int position, @NonNull RecyclerView.ViewHolder holder, @NonNull List payloads) { + AppStatus app = (AppStatus) items.get(position); + ((AppListItemController) holder).bindModel(app.status.app); + } + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppUpdateData.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppUpdateData.java new file mode 100644 index 000000000..173cbbeb7 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/AppUpdateData.java @@ -0,0 +1,16 @@ +package org.fdroid.fdroid.views.updates.items; + +import android.app.Activity; + +/** + * Used as a common base class for all data types in the {@link org.fdroid.fdroid.views.updates.UpdatesAdapter}. + * Doesn't have any functionality of its own, but allows the {@link org.fdroid.fdroid.views.updates.UpdatesAdapter#delegatesManager} + * to specify a data type more specific than just {@link Object}. + */ +public abstract class AppUpdateData { + public final Activity activity; + + public AppUpdateData(Activity activity) { + this.activity = activity; + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/DonationPrompt.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/DonationPrompt.java new file mode 100644 index 000000000..73e306b5f --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/DonationPrompt.java @@ -0,0 +1,54 @@ +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; + +/** + * The app (if any) which we should prompt the user about potentially donating to (due to having + * updated several times). + */ +public class DonationPrompt extends AppUpdateData { + + public DonationPrompt(Activity activity) { + super(activity); + } + + public static class Delegate extends AdapterDelegate> { + + @Override + protected boolean isForViewType(@NonNull List items, int position) { + return items.get(position) instanceof DonationPrompt; + } + + @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 payloads) { + DonationPrompt app = (DonationPrompt) items.get(position); + ((ViewHolder) holder).bindApp(app); + } + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + public void bindApp(DonationPrompt app) { + ((TextView) itemView).setText("Donation prompt for app"); + } + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableApp.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableApp.java new file mode 100644 index 000000000..77fc9ad14 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableApp.java @@ -0,0 +1,57 @@ +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.ViewGroup; + +import com.hannesdorfmann.adapterdelegates3.AdapterDelegate; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.views.apps.AppListItemController; + +import java.util.List; + +/** + * List of all apps which can be updated, but have not yet been downloaded. + * @see UpdateableApp The data that is bound to this view. + * @see R.layout#updateable_app_list_item The view that this binds to. + * @see AppListItemController Used for binding the {@link App} to the {@link R.layout#updateable_app_list_item} + */ +public class UpdateableApp extends AppUpdateData { + + public final App app; + + public UpdateableApp(Activity activity, App app) { + super(activity); + this.app = app; + } + + public static class Delegate extends AdapterDelegate> { + + private final Activity activity; + + public Delegate(Activity activity) { + this.activity = activity; + } + + @Override + protected boolean isForViewType(@NonNull List items, int position) { + return items.get(position) instanceof UpdateableApp; + } + + @NonNull + @Override + protected RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent) { + return new AppListItemController(activity, activity.getLayoutInflater().inflate(R.layout.updateable_app_list_item, parent, false)); + } + + @Override + protected void onBindViewHolder(@NonNull List items, int position, @NonNull RecyclerView.ViewHolder holder, @NonNull List payloads) { + UpdateableApp app = (UpdateableApp) items.get(position); + ((AppListItemController) holder).bindModel(app.app); + } + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableAppsHeader.java b/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableAppsHeader.java new file mode 100644 index 000000000..cd1e4c3ed --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableAppsHeader.java @@ -0,0 +1,120 @@ +package org.fdroid.fdroid.views.updates.items; + +import android.app.Activity; +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import com.hannesdorfmann.adapterdelegates3.AdapterDelegate; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.UpdateService; +import org.fdroid.fdroid.views.updates.UpdatesAdapter; + +import java.util.ArrayList; +import java.util.List; + +/** + * Summary of all apps that can be downloaded. Includes a button to download all of them and also + * a toggle to show or hide the list of each individual item. + * @see R.layout#updates_header The view that this binds to. + * @see UpdateableAppsHeader The data that is bound to this view. + */ +public class UpdateableAppsHeader extends AppUpdateData { + + public final List apps; + public final UpdatesAdapter adapter; + + public UpdateableAppsHeader(Activity activity, UpdatesAdapter updatesAdapter, List updateableApps) { + super(activity); + apps = updateableApps; + adapter = updatesAdapter; + } + + public static class Delegate extends AdapterDelegate> { + + private final LayoutInflater inflater; + + public Delegate(Activity activity) { + inflater = activity.getLayoutInflater(); + } + + @Override + protected boolean isForViewType(@NonNull List items, int position) { + return items.get(position) instanceof UpdateableAppsHeader; + } + + @NonNull + @Override + protected RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent) { + return new ViewHolder(inflater.inflate(R.layout.updates_header, parent, false)); + } + + @Override + protected void onBindViewHolder(@NonNull List items, int position, @NonNull RecyclerView.ViewHolder holder, @NonNull List payloads) { + UpdateableAppsHeader app = (UpdateableAppsHeader) items.get(position); + ((ViewHolder) holder).bindHeader(app); + } + } + + public static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + + private UpdateableAppsHeader header; + + private final TextView updatesAvailable; + private final ImageView downloadAll; + private final TextView appsToUpdate; + private final Button toggleAppsToUpdate; + + public ViewHolder(View itemView) { + super(itemView); + + updatesAvailable = (TextView) itemView.findViewById(R.id.text_updates_available); + downloadAll = (ImageView) itemView.findViewById(R.id.button_download_all); + appsToUpdate = (TextView) itemView.findViewById(R.id.text_apps_to_update); + toggleAppsToUpdate = (Button) itemView.findViewById(R.id.button_toggle_apps_to_update); + + toggleAppsToUpdate.setOnClickListener(this); + downloadAll.setOnClickListener(this); + } + + public void bindHeader(UpdateableAppsHeader header) { + this.header = header; + + updatesAvailable.setText(itemView.getResources().getQuantityString(R.plurals.updates__download_updates_for_apps, header.apps.size(), header.apps.size())); + + List appNames = new ArrayList<>(header.apps.size()); + for (UpdateableApp app : header.apps) { + appNames.add(app.app.name); + } + + appsToUpdate.setText(TextUtils.join(", ", appNames)); + updateToggleButtonText(); + } + + @Override + public void onClick(View v) { + if (v == toggleAppsToUpdate) { + header.adapter.toggleAllUpdateableApps(); + updateToggleButtonText(); + } else if (v == downloadAll) { + UpdateService.autoDownloadUpdates(header.activity); + } + } + + private void updateToggleButtonText() { + if (header.adapter.canViewAllUpdateableApps()) { + toggleAppsToUpdate.setText(R.string.updates__hide_updateable_apps); + } else { + toggleAppsToUpdate.setText(R.string.updates__show_updateable_apps); + } + } + } + +} diff --git a/app/src/main/res/color/bottom_nav_items.xml b/app/src/main/res/color/bottom_nav_items.xml new file mode 100644 index 000000000..ce3b9b1d1 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_items.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml new file mode 100644 index 000000000..b72685b11 --- /dev/null +++ b/app/src/main/res/drawable/badge_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_primary_background_selector.xml b/app/src/main/res/drawable/button_primary_background_selector.xml index ee21a1543..09a259bf2 100644 --- a/app/src/main/res/drawable/button_primary_background_selector.xml +++ b/app/src/main/res/drawable/button_primary_background_selector.xml @@ -2,25 +2,25 @@ - + - + - + - + diff --git a/app/src/main/res/drawable/button_secondary_background_selector.xml b/app/src/main/res/drawable/button_secondary_background_selector.xml index 2cb0d8ebb..49b527351 100644 --- a/app/src/main/res/drawable/button_secondary_background_selector.xml +++ b/app/src/main/res/drawable/button_secondary_background_selector.xml @@ -2,25 +2,25 @@ - + - + - + - + diff --git a/app/src/main/res/drawable/ic_categories.xml b/app/src/main/res/drawable/ic_categories.xml new file mode 100644 index 000000000..ab39a6725 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_category.xml b/app/src/main/res/drawable/ic_category.xml deleted file mode 100644 index b71523ac0..000000000 --- a/app/src/main/res/drawable/ic_category.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_latest.xml b/app/src/main/res/drawable/ic_latest.xml new file mode 100644 index 000000000..c9df81f3a --- /dev/null +++ b/app/src/main/res/drawable/ic_latest.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/drawable/ic_my_apps.xml b/app/src/main/res/drawable/ic_my_apps.xml deleted file mode 100644 index 8bb1ec68b..000000000 --- a/app/src/main/res/drawable/ic_my_apps.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_nearby.xml b/app/src/main/res/drawable/ic_nearby.xml index c05402310..cbf95c45b 100644 --- a/app/src/main/res/drawable/ic_nearby.xml +++ b/app/src/main/res/drawable/ic_nearby.xml @@ -1,12 +1,18 @@ - + + + + + - - + android:pathData="m4.47,27.01c-0.16,-0.98 -0.25,-1.98 -0.25,-3.01 0,-10.22 8.31,-18.53 18.53,-18.53 4.68,0 8.97,1.75 12.23,4.63C35.81,9.08 36.85,8.24 38.03,7.64 34.02,3.9 28.65,1.6 22.75,1.6 10.4,1.6 0.35,11.65 0.35,24c0,2.1 0.3,4.13 0.84,6.05 0.87,-1.23 1.99,-2.27 3.28,-3.04" + android:strokeColor="#00000000" android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/ic_overview.xml b/app/src/main/res/drawable/ic_overview.xml deleted file mode 100644 index 8aec73afc..000000000 --- a/app/src/main/res/drawable/ic_overview.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 7fb01b86a..583bfe9f1 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/drawable/ic_updates.xml b/app/src/main/res/drawable/ic_updates.xml new file mode 100644 index 000000000..7da41aced --- /dev/null +++ b/app/src/main/res/drawable/ic_updates.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 60c98a6a3..85aacae88 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,22 +6,19 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:layout_alignParentBottom="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + android:layout_height="match_parent" + tools:listitem="@layout/main_tab_whats_new" /> \ No newline at end of file diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 5cba0bfe9..823d69ec7 100755 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -114,6 +114,7 @@ android:layout_toRightOf="@id/icon" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" + android:clipToPadding="false" android:visibility="gone" > @@ -125,7 +126,8 @@ android:layout_weight="1" android:maxLines="1" android:ellipsize="marquee" - android:text="THIS IS BUTTON 1" /> + android:padding="12dp" + tools:text="THIS IS BUTTON 1" />