From 40cc328e9805cf4b7e8517d86a0a24646edb5d34 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Tue, 14 Mar 2017 08:32:56 +1100 Subject: [PATCH] Updates: Implemented new UI for "Updates" screen. Alows for more flexibility in what we are able to display, including: * Prompting users to donate to frequently updated apps * Showing messages from package maintainers to users * Marking apps for later installation when offline Most of these are not yet implemented, but will be able to when required, whereas they were not able to in the previous UI. --- .../fdroid/fdroid/AppUpdateStatusManager.java | 30 +- .../fdroid/views/main/MainViewController.java | 6 +- .../myapps/InstalledHeaderController.java | 10 - .../fdroid/views/myapps/MyAppsAdapter.java | 92 ----- .../fdroid/views/myapps/MyAppsViewBinder.java | 77 ---- .../views/myapps/UpdatesHeaderController.java | 38 -- .../fdroid/views/updates/UpdatesAdapter.java | 355 ++++++++++++++++++ .../views/updates/UpdatesViewBinder.java | 26 ++ .../views/updates/items/AppNotification.java | 56 +++ .../fdroid/views/updates/items/AppStatus.java | 57 +++ .../views/updates/items/AppUpdateData.java | 16 + .../views/updates/items/DonationPrompt.java | 54 +++ .../views/updates/items/UpdateableApp.java | 57 +++ .../updates/items/UpdateableAppsHeader.java | 120 ++++++ .../{main_tabs.xml => main_tab_updates.xml} | 10 +- .../res/layout/my_apps_updates_header.xml | 33 -- .../res/layout/updateable_app_list_item.xml | 62 +++ .../res/layout/updateable_app_status_item.xml | 87 +++++ app/src/main/res/layout/updates_header.xml | 61 +++ app/src/main/res/values/ids.xml | 3 - app/src/main/res/values/strings.xml | 15 +- 21 files changed, 990 insertions(+), 275 deletions(-) delete mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java delete mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java delete mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java delete mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesViewBinder.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/AppNotification.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/AppStatus.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/AppUpdateData.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/DonationPrompt.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableApp.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/updates/items/UpdateableAppsHeader.java rename app/src/main/res/layout/{main_tabs.xml => main_tab_updates.xml} (59%) delete mode 100644 app/src/main/res/layout/my_apps_updates_header.xml create mode 100644 app/src/main/res/layout/updateable_app_list_item.xml create mode 100644 app/src/main/res/layout/updateable_app_status_item.xml create mode 100644 app/src/main/res/layout/updates_header.xml diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index 91d5b965f..8ec8cd1e4 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -63,6 +63,13 @@ public final class AppUpdateStatusManager { 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. @@ -174,9 +181,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); } } @@ -220,7 +229,7 @@ public final class AppUpdateStatusManager { for (Apk apk : apksToUpdate) { addApk(apk, status, null); } - endBatchUpdates(); + endBatchUpdates(status); } /** @@ -318,10 +327,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); } } @@ -333,7 +349,7 @@ public final class AppUpdateStatusManager { it.remove(); } } - notifyChange(); + notifyChange(REASON_CLEAR_ALL_UPDATES); } } @@ -345,7 +361,7 @@ public final class AppUpdateStatusManager { it.remove(); } } - notifyChange(); + notifyChange(REASON_CLEAR_ALL_INSTALLED); } } 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 ef006d4ef..b0a674ac3 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 @@ -10,7 +10,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; /** @@ -38,10 +38,10 @@ class MainViewController extends RecyclerView.ViewHolder { } /** - * @see MyAppsViewBinder + * @see UpdatesViewBinder */ public void bindUpdates() { - new MyAppsViewBinder(activity, frame); + new UpdatesViewBinder(activity, frame); } /** 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..fc449f5a1 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java @@ -0,0 +1,355 @@ +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.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()); + } 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); + } + + 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..9157099a7 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesViewBinder.java @@ -0,0 +1,26 @@ +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 { + + public UpdatesViewBinder(AppCompatActivity activity, FrameLayout parent) { + View view = activity.getLayoutInflater().inflate(R.layout.main_tab_updates, parent, true); + + UpdatesAdapter adapter = new UpdatesAdapter(activity); + + // TODO: Find the right time to stop listening for status updates. + adapter.listenForStatusUpdates(); + + RecyclerView list = (RecyclerView) view.findViewById(R.id.list); + list.setHasFixedSize(true); + list.setLayoutManager(new LinearLayoutManager(activity)); + list.setAdapter(adapter); + } +} 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/layout/main_tabs.xml b/app/src/main/res/layout/main_tab_updates.xml similarity index 59% rename from app/src/main/res/layout/main_tabs.xml rename to app/src/main/res/layout/main_tab_updates.xml index d47fb717d..308ae2a92 100644 --- a/app/src/main/res/layout/main_tabs.xml +++ b/app/src/main/res/layout/main_tab_updates.xml @@ -1,7 +1,6 @@ - @@ -11,9 +10,6 @@ tools:listitem="@layout/app_list_item" android:layout_width="match_parent" android:layout_height="match_parent" - android:scrollbars="vertical" - app:layout_constraintTop_toBottomOf="@+id/update_all_button" - app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintRight_toRightOf="parent" /> + android:scrollbars="vertical" /> - + diff --git a/app/src/main/res/layout/my_apps_updates_header.xml b/app/src/main/res/layout/my_apps_updates_header.xml deleted file mode 100644 index 3ce423ae9..000000000 --- a/app/src/main/res/layout/my_apps_updates_header.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - -