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 @@ + + + + + + + + + +