From 53df5473f54070929dc3822c318d6f1d42c762ad Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Thu, 24 Nov 2016 06:56:10 +1100 Subject: [PATCH] My Apps: Added the list of updateable apps to the main view. Not fully featured yet, because it doesn't listen for broadcasts from the installers, but it is shows the correct list of apps and allows users to queue up downloads of all updateable apps. --- .../org/fdroid/fdroid/data/AppProvider.java | 29 ++-- .../fdroid/fdroid/data/QuerySelection.java | 4 + .../views/apps/AppListItemController.java | 148 ++++++++++++++++++ .../fdroid/views/main/MainViewController.java | 5 + .../myapps/InstalledHeaderController.java | 10 ++ .../fdroid/views/myapps/MyAppsAdapter.java | 76 +++++++++ .../fdroid/views/myapps/MyAppsViewBinder.java | 77 +++++++++ .../views/myapps/UpdatesHeaderController.java | 38 +++++ .../res/drawable/app_list_item_divider.xml | 10 ++ app/src/main/res/drawable/download_button.xml | 7 + .../main/res/drawable/ic_download_button.xml | 13 ++ app/src/main/res/layout/app_list_item.xml | 78 +++++++++ app/src/main/res/layout/main_tabs.xml | 19 +++ .../res/layout/my_apps_updates_header.xml | 32 ++++ app/src/main/res/values/ids.xml | 3 + app/src/main/res/values/strings.xml | 8 + .../fdroid/fdroid/data/AppProviderTest.java | 5 + 17 files changed, 553 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java create mode 100644 app/src/main/res/drawable/app_list_item_divider.xml create mode 100644 app/src/main/res/drawable/download_button.xml create mode 100644 app/src/main/res/drawable/ic_download_button.xml create mode 100644 app/src/main/res/layout/app_list_item.xml create mode 100644 app/src/main/res/layout/main_tabs.xml create mode 100644 app/src/main/res/layout/my_apps_updates_header.xml diff --git a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java index 122885612..349f71fa0 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java @@ -199,16 +199,26 @@ public class AppProvider extends FDroidProvider { public AppQuerySelection add(AppQuerySelection query) { QuerySelection both = super.add(query); AppQuerySelection bothWithJoin = new AppQuerySelection(both.getSelection(), both.getArgs()); - if (this.naturalJoinToInstalled() || query.naturalJoinToInstalled()) { - bothWithJoin.requireNaturalInstalledTable(); - } - - if (this.leftJoinToPrefs() || query.leftJoinToPrefs()) { - bothWithJoin.requireLeftJoinPrefs(); - } + ensureJoinsCopied(query, bothWithJoin); return bothWithJoin; } + public AppQuerySelection not(AppQuerySelection query) { + QuerySelection both = super.not(query); + AppQuerySelection bothWithJoin = new AppQuerySelection(both.getSelection(), both.getArgs()); + ensureJoinsCopied(query, bothWithJoin); + return bothWithJoin; + } + + private void ensureJoinsCopied(AppQuerySelection toAdd, AppQuerySelection newlyCreated) { + if (this.naturalJoinToInstalled() || toAdd.naturalJoinToInstalled()) { + newlyCreated.requireNaturalInstalledTable(); + } + + if (this.leftJoinToPrefs() || toAdd.leftJoinToPrefs()) { + newlyCreated.requireLeftJoinPrefs(); + } + } } protected class Query extends QueryBuilder { @@ -564,7 +574,8 @@ public class AppProvider extends FDroidProvider { final String ignoreAll = "COALESCE(prefs." + AppPrefsTable.Cols.IGNORE_ALL_UPDATES + ", 0) != 1"; final String ignore = " (" + ignoreCurrent + " AND " + ignoreAll + ") "; - final String where = ignore + " AND " + app + "." + Cols.SUGGESTED_VERSION_CODE + " > installed." + InstalledAppTable.Cols.VERSION_CODE; + final String nullChecks = app + "." + Cols.SUGGESTED_VERSION_CODE + " IS NOT NULL AND installed." + InstalledAppTable.Cols.VERSION_CODE + " IS NOT NULL "; + final String where = nullChecks + " AND " + ignore + " AND " + app + "." + Cols.SUGGESTED_VERSION_CODE + " > installed." + InstalledAppTable.Cols.VERSION_CODE; return new AppQuerySelection(where).requireNaturalInstalledTable().requireLeftJoinPrefs(); } @@ -576,7 +587,7 @@ public class AppProvider extends FDroidProvider { } private AppQuerySelection queryInstalled() { - return new AppQuerySelection().requireNaturalInstalledTable(); + return new AppQuerySelection().requireNaturalInstalledTable().not(queryCanUpdate()); } private AppQuerySelection querySearch(String query) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java b/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java index 5c32395a7..d5461799f 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java +++ b/app/src/main/java/org/fdroid/fdroid/data/QuerySelection.java @@ -78,4 +78,8 @@ public class QuerySelection { return new QuerySelection(s, a); } + public QuerySelection not(QuerySelection querySelection) { + String where = " NOT (" + querySelection.getSelection() + ") "; + return add(where, querySelection.getArgs()); + } } 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 new file mode 100644 index 000000000..3da0e8428 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -0,0 +1,148 @@ +package org.fdroid.fdroid.views.apps; + +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ActivityOptionsCompat; +import android.support.v4.util.Pair; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.fdroid.fdroid.AppDetails; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.installer.InstallManagerService; + +public class AppListItemController extends RecyclerView.ViewHolder { + + private final Activity activity; + + private final Button installButton; + private final ImageView icon; + private final TextView name; + private final TextView status; + private final DisplayImageOptions displayImageOptions; + + private App currentApp; + + public AppListItemController(Activity activity, View itemView) { + super(itemView); + this.activity = activity; + + installButton = (Button) itemView.findViewById(R.id.install); + installButton.setOnClickListener(onInstallClicked); + + icon = (ImageView) itemView.findViewById(R.id.icon); + name = (TextView) itemView.findViewById(R.id.app_name); + status = (TextView) itemView.findViewById(R.id.status); + + displayImageOptions = Utils.getImageLoadingOptions().build(); + + itemView.setOnClickListener(onAppClicked); + } + + public void bindModel(@NonNull App app) { + currentApp = app; + name.setText(Utils.formatAppNameAndSummary(app.name, app.summary)); + + ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions); + + configureStatusText(app); + configureInstallButton(app); + } + + /** + * Sets the text/visibility of the {@link R.id#status} {@link TextView} based on whether the app: + * * 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) { + return; + } + + if (!app.compatible) { + status.setText(activity.getString(R.string.app_incompatible)); + status.setVisibility(View.VISIBLE); + } else if (app.isInstalled()) { + if (app.canAndWantToUpdate(activity)) { + String upgradeFromTo = activity.getString(R.string.app_version_x_available, app.getSuggestedVersionName()); + status.setText(upgradeFromTo); + } else { + String installed = activity.getString(R.string.app_version_x_installed, app.installedVersionName); + status.setText(installed); + } + + status.setVisibility(View.VISIBLE); + } else { + status.setVisibility(View.INVISIBLE); + } + + } + + /** + * 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) { + return; + } + + boolean installable = app.canAndWantToUpdate(activity) || !app.isInstalled(); + boolean shouldAllow = app.compatible && !app.isFiltered(); + + if (shouldAllow && installable) { + installButton.setVisibility(View.VISIBLE); + } else { + installButton.setVisibility(View.GONE); + } + } + + private final View.OnClickListener onAppClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentApp == null) { + return; + } + + Intent intent = new Intent(activity, AppDetails.class); + intent.putExtra(AppDetails.EXTRA_APPID, currentApp.packageName); + if (Build.VERSION.SDK_INT >= 21) { + Pair iconTransitionPair = Pair.create((View) icon, activity.getString(R.string.transition_app_item_icon)); + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, iconTransitionPair).toBundle(); + activity.startActivity(intent, bundle); + } else { + activity.startActivity(intent); + } + } + }; + + private final View.OnClickListener onInstallClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (currentApp == null) { + return; + } + + InstallManagerService.queue(activity, currentApp, ApkProvider.Helper.findApkFromAnyRepo(activity, currentApp.packageName, currentApp.suggestedVersionCode)); + } + }; +} 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 8d894584c..668a44f9a 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 @@ -8,6 +8,7 @@ import android.widget.Button; import android.widget.FrameLayout; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.views.myapps.MyAppsViewBinder; import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; /** @@ -38,7 +39,11 @@ class MainViewController extends RecyclerView.ViewHolder { new WhatsNewViewBinder(activity, frame); } + /** + * @see MyAppsViewBinder + */ public void bindMyApps() { + new MyAppsViewBinder(activity, frame); } public void bindCategoriesView() { 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 new file mode 100644 index 000000000..c8adba2ce --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/InstalledHeaderController.java @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000..4c29069c8 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsAdapter.java @@ -0,0 +1,76 @@ +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; + +/** + * 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; + + public MyAppsAdapter(Activity activity) { + this.activity = 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(); + } +} 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 new file mode 100644 index 000000000..8e3ec2c73 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/MyAppsViewBinder.java @@ -0,0 +1,77 @@ +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 new file mode 100644 index 000000000..1bbf61210 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/myapps/UpdatesHeaderController.java @@ -0,0 +1,38 @@ +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/res/drawable/app_list_item_divider.xml b/app/src/main/res/drawable/app_list_item_divider.xml new file mode 100644 index 000000000..4e5abfa49 --- /dev/null +++ b/app/src/main/res/drawable/app_list_item_divider.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_button.xml b/app/src/main/res/drawable/download_button.xml new file mode 100644 index 000000000..4faebbd9f --- /dev/null +++ b/app/src/main/res/drawable/download_button.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download_button.xml b/app/src/main/res/drawable/ic_download_button.xml new file mode 100644 index 000000000..562b5f20e --- /dev/null +++ b/app/src/main/res/drawable/ic_download_button.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/app_list_item.xml b/app/src/main/res/layout/app_list_item.xml new file mode 100644 index 000000000..172df3d53 --- /dev/null +++ b/app/src/main/res/layout/app_list_item.xml @@ -0,0 +1,78 @@ + + + + + + + + + +