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.
This commit is contained in:
Peter Serwylo 2016-11-24 06:56:10 +11:00
parent 69b58c7646
commit 53df5473f5
17 changed files with 553 additions and 9 deletions

View File

@ -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) {

View File

@ -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());
}
}

View File

@ -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<View, String> 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));
}
};
}

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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<RecyclerView.ViewHolder> {
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();
}
}

View File

@ -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<Cursor> {
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<Cursor> 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<Cursor> loader, Cursor cursor) {
adapter.setApps(cursor);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
adapter.setApps(null);
}
}

View File

@ -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);
}
};
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Used to separate two sepparate R.layout.app_list_item views in a list.
As these are not cards, they don't have their own drop shadow or other features that help
separate different list items.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:width="1dp" android:height="1dp" />
<solid android:color="#ffe3e3e3" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Shown in the app list item as a shortcut for the user to be able to download/install an app.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_download_button" />
</selector>

View File

@ -0,0 +1,13 @@
<vector android:height="32dp" android:viewportHeight="74.53289"
android:viewportWidth="74.53289" android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#0066cc"
android:pathData="m37.27,0c-20.57,0 -37.27,16.7 -37.27,37.27 0,20.57 16.7,37.27 37.27,37.27 20.57,0 37.27,-16.7 37.27,-37.27 0,-20.57 -16.7,-37.27 -37.27,-37.27zM37.27,2c19.49,0 35.27,15.78 35.27,35.27 0,19.49 -15.78,35.27 -35.27,35.27 -19.49,0 -35.27,-15.78 -35.27,-35.27 0,-19.49 15.78,-35.27 35.27,-35.27z"
android:strokeAlpha="1" android:strokeColor="#00000000"
android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeWidth="2"/>
<path android:fillAlpha="1" android:fillColor="#0066cc"
android:pathData="M23.05,49.12h27.97v4.04h-27.97z"
android:strokeAlpha="1" android:strokeColor="#00000000" android:strokeWidth="2"/>
<path android:fillAlpha="1" android:fillColor="#0066cc"
android:pathData="m31.07,19.19 l0,12.18 -7.71,0 13.86,13.57 13.86,-13.57 -7.83,0 0,-12.18z"
android:strokeAlpha="1" android:strokeColor="#00000000" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/app_icon"
android:layout_width="48dp"
android:layout_height="48dp"
tools:src="@drawable/ic_launcher"
android:scaleType="fitCenter"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="F-Droid Application manager with a long name that will wrap and then ellipsize"
android:textSize="18sp"
android:textColor="#424242"
android:lines="2"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="@+id/icon"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
app:layout_constraintEnd_toStartOf="@+id/install"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:text="Installed"
android:textStyle="italic"
android:textSize="14sp"
android:textColor="#424242"
android:maxLines="1"
android:ellipsize="end"
android:fontFamily="sans-serif-light"
app:layout_constraintTop_toBottomOf="@+id/app_name"
app:layout_constraintStart_toEndOf="@+id/icon"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp" />
<Button
android:id="@+id/install"
android:background="@drawable/download_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:padding="4dp"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/icon" />
<ImageView
android:layout_width="0dp"
android:layout_height="1dp"
android:scaleType="fitXY"
android:src="@drawable/app_list_item_divider"
app:layout_constraintTop_toBottomOf="@+id/status"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp"
tools:ignore="ContentDescription" />
</android.support.constraint.ConstraintLayout>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
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" />
</LinearLayout>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingStart="16dp"
android:paddingRight="16dp"
android:paddingEnd="16dp"
android:layout_marginTop="16dp"
android:paddingBottom="8dp"
android:clipToPadding="false">
<Button
android:id="@+id/update_all_button"
android:text="@string/my_apps_btn_update_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true" />
<TextView
android:id="@+id/updates_heading"
tools:text="2 Updates"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignBaseline="@+id/update_all_button" />
</RelativeLayout>

View File

@ -7,4 +7,7 @@
<item type="id" name="whats_new_large_tile" />
<item type="id" name="whats_new_small_tile" />
<item type="id" name="whats_new_regular_list" />
<item type="id" name="my_apps__header" />
<item type="id" name="my_apps__app" />
</resources>

View File

@ -63,9 +63,17 @@
<string name="app_not_installed">Not Installed</string>
<string name="app_inst_known_source">Installed (from %s)</string>
<string name="app_inst_unknown_source">Installed (from unknown source)</string>
<string name="app_version_x_available">Version %1$s available</string>
<string name="app_version_x_installed">Version %1$s</string>
<string name="added_on">Added on %s</string>
<string name="my_apps_btn_update_all">Update all</string>
<plurals name="my_apps_header_number_of_updateable">
<item quantity="one">%1$d Update</item>
<item quantity="other">%1$d Updates</item>
</plurals>
<string name="ok">OK</string>
<string name="yes">Yes</string>

View File

@ -114,6 +114,9 @@ public class AppProviderTest extends FDroidProviderTest {
App notInstalled = AppProvider.Helper.findSpecificApp(r, "not installed", 1, Cols.ALL);
assertFalse(notInstalled.canAndWantToUpdate(context));
assertResultCount(contentResolver, 2, AppProvider.getCanUpdateUri(), PROJ);
assertResultCount(contentResolver, 7, AppProvider.getInstalledUri(), PROJ);
App installedOnlyOneVersionAvailable = AppProvider.Helper.findSpecificApp(r, "installed, only one version available", 1, Cols.ALL);
App installedAlreadyLatestNoIgnore = AppProvider.Helper.findSpecificApp(r, "installed, already latest, no ignore", 1, Cols.ALL);
App installedAlreadyLatestIgnoreAll = AppProvider.Helper.findSpecificApp(r, "installed, already latest, ignore all", 1, Cols.ALL);
@ -206,12 +209,14 @@ public class AppProviderTest extends FDroidProviderTest {
insertApps(100);
assertResultCount(contentResolver, 100, AppProvider.getContentUri(), PROJ);
assertResultCount(contentResolver, 0, AppProvider.getCanUpdateUri(), PROJ);
assertResultCount(contentResolver, 0, AppProvider.getInstalledUri(), PROJ);
for (int i = 10; i < 20; i++) {
InstalledAppTestUtils.install(context, "com.example.test." + i, i, "v1");
}
assertResultCount(contentResolver, 0, AppProvider.getCanUpdateUri(), PROJ);
assertResultCount(contentResolver, 10, AppProvider.getInstalledUri(), PROJ);
}