Refactor the AppListItemControler to be more predictable.

Previously, there were different pieces of business logic, invoked at
different times, which would touch subsets of the UI.

This change rips that out, and replaces it with a single place where the
UI is setup. This can always be called safely, and it will render the
correct data for the current state of the app (e.g. downloading, waiting
for install, etc).

The AppListItemState class is a dumb object which keeps track of what is
supposed to be displayed in the UI. The AppListItemController now
creates a different AppListItemState depending on what state the list
item is in. This AppListItemState is then used to bind the values of
each UI widget.

All of the binding code is now in the single `resetView()` method, but
all of the business logic for what the view should look like is
separated into different `getViewState*()` methods.

This separation should make it easier to make sense of the UI code, and
hopefully should be testable should somebody choose to write tests for
it in the future.
This commit is contained in:
Peter Serwylo 2017-06-28 16:30:42 +10:00
parent 61ab88f8ea
commit d0cf621314
2 changed files with 250 additions and 220 deletions

View File

@ -37,7 +37,6 @@ import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.ApkProvider;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppPrefs;
import org.fdroid.fdroid.installer.ApkCache;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.installer.Installer;
@ -156,7 +155,7 @@ public class AppListItemController extends RecyclerView.ViewHolder {
AppUpdateStatusManager.AppUpdateStatus status = statuses.next();
updateAppStatus(app, status);
} else {
currentStatus = null;
updateAppStatus(app, null);
}
}
@ -168,223 +167,161 @@ public class AppListItemController extends RecyclerView.ViewHolder {
refreshStatus(app);
final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(activity.getApplicationContext());
broadcastManager.unregisterReceiver(onInstallAction);
broadcastManager.unregisterReceiver(onStatusChanged);
// broadcastManager.registerReceiver(onInstallAction, Installer.getInstallIntentFilter(Uri.parse(currentAppDownloadUrl)));
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED);
intentFilter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED);
intentFilter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_CHANGED);
broadcastManager.registerReceiver(onStatusChanged, intentFilter);
configureAppName(app);
configureStatusText(app);
configureInstalledVersion(app);
configureIgnoredStatus(app);
configureInstallButton(app);
configureActionButton(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
*/
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);
@NonNull
private AppListItemState getCurrentViewState(
@NonNull App app, @Nullable AppUpdateStatusManager.AppUpdateStatus appStatus) {
if (appStatus == null) {
return getViewStateDefault(app);
} else {
status.setVisibility(View.INVISIBLE);
}
switch (appStatus.status) {
case ReadyToInstall:
return getViewStateReadyToInstall(app);
}
case Downloading:
return getViewStateDownloading(app, appStatus);
/**
* Shows the currently installed version name, and whether or not it is the recommended version.
* Binds to the {@link R.id#installed_version} {@link TextView}.
*/
private void configureInstalledVersion(@NonNull App app) {
if (installedVersion == null) {
return;
}
case Installed:
return getViewStateInstalled(app);
int res = (app.suggestedVersionCode == app.installedVersionCode)
? R.string.app_recommended_version_installed : R.string.app_version_x_installed;
installedVersion.setText(activity.getString(res, app.installedVersionName));
}
/**
* Shows whether the user has previously asked to ignore updates for this app entirely, or for a
* specific version of this app. Binds to the {@link R.id#ignored_status} {@link TextView}.
*/
private void configureIgnoredStatus(@NonNull App app) {
if (ignoredStatus == null) {
return;
}
AppPrefs prefs = app.getPrefs(activity);
if (prefs.ignoreAllUpdates) {
ignoredStatus.setText(activity.getString(R.string.installed_app__updates_ignored));
ignoredStatus.setVisibility(View.VISIBLE);
} else if (prefs.ignoreThisUpdate > 0 && prefs.ignoreThisUpdate == app.suggestedVersionCode) {
ignoredStatus.setText(activity.getString(R.string.installed_app__updates_ignored_for_suggested_version, app.getSuggestedVersionName()));
ignoredStatus.setVisibility(View.VISIBLE);
} else {
ignoredStatus.setVisibility(View.GONE);
}
}
/**
* Queries the {@link AppUpdateStatusManager} to find out if there are any apks corresponding to
* `app` which are ready to install.
*/
private boolean isReadyToInstall(@NonNull App app) {
for (AppUpdateStatusManager.AppUpdateStatus appStatus : AppUpdateStatusManager.getInstance(activity).getByPackageName(app.packageName)) {
if (appStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) {
return true;
default:
return getViewStateDefault(app);
}
}
return false;
}
/**
* The app name {@link TextView} is used for a few reasons:
* <li> Display name + summary of the app (most common).
* <li> If downloading, mention that it is downloading instead of showing the summary.
* <li> If downloaded and ready to install, mention that it is ready to update/install.
*/
private void configureAppName(@NonNull App app) {
private void refreshView(@NonNull App app,
@Nullable AppUpdateStatusManager.AppUpdateStatus appStatus) {
AppListItemState viewState = getCurrentViewState(app, appStatus);
name.setText(viewState.getMainText());
if (downloadReady != null) {
downloadReady.setVisibility(View.GONE);
}
if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) {
name.setText(app.name);
if (downloadReady != null) {
downloadReady.setVisibility(View.VISIBLE);
}
} else if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.Downloading) {
name.setText(activity.getString(R.string.app_list__name__downloading_in_progress, app.name));
} else if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.Installed) {
name.setText(activity.getString(R.string.app_list__name__successfully_installed, app.name));
} else {
name.setText(Utils.formatAppNameAndSummary(app.name, app.summary));
}
}
/**
* The action button will either tell the user to "Update" or "Install" the app. Both actually do
* the same thing (launch the package manager). It depends on whether the app has a previous
* version installed or not as to the chosen terminology.
*/
private void configureActionButton(@NonNull App app) {
if (actionButton == null) {
return;
downloadReady.setVisibility(viewState.shouldShowActionButton() ? View.VISIBLE : View.GONE);
}
actionButton.setVisibility(View.VISIBLE);
if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.Installed) {
if (activity.getPackageManager().getLaunchIntentForPackage(app.packageName) != null) {
actionButton.setText(R.string.menu_launch);
if (actionButton != null) {
if (viewState.shouldShowActionButton()) {
actionButton.setVisibility(View.VISIBLE);
actionButton.setText(viewState.getActionButtonText());
} else {
actionButton.setVisibility(View.GONE);
}
} else if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) {
if (app.isInstalled()) {
actionButton.setText(R.string.app__install_downloaded_update);
}
if (progressBar != null) {
if (viewState.showProgress()) {
progressBar.setVisibility(View.VISIBLE);
if (viewState.isProgressIndeterminate()) {
progressBar.setIndeterminate(true);
} else {
progressBar.setIndeterminate(false);
progressBar.setMax(viewState.getProgressMax());
progressBar.setProgress(viewState.getProgressCurrent());
}
} else {
actionButton.setText(R.string.menu_install);
progressBar.setVisibility(View.GONE);
}
} else {
actionButton.setVisibility(View.GONE);
}
}
/**
* 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.
*/
private void configureInstallButton(@NonNull App app) {
if (installButton == null) {
return;
}
if (isReadyToInstall(app)) {
installButton.setVisibility(View.GONE);
} else {
boolean installable = app.canAndWantToUpdate(activity) || !app.isInstalled();
boolean shouldAllow = app.compatible && !app.isFiltered();
if (cancelButton != null) {
if (viewState.showProgress()) {
cancelButton.setVisibility(View.VISIBLE);
} else {
cancelButton.setVisibility(View.GONE);
}
}
if (shouldAllow && installable) {
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download));
if (installButton != null) {
if (viewState.shouldShowActionButton()) {
installButton.setVisibility(View.GONE);
} else if (viewState.showProgress()) {
installButton.setVisibility(View.VISIBLE);
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
int progressAsDegrees = viewState.getProgressMax() <= 0 ? 0 :
(int) (((float) viewState.getProgressCurrent() / viewState.getProgressMax()) * 360);
installButton.setImageLevel(progressAsDegrees);
} else if (viewState.shouldShowInstall()) {
installButton.setVisibility(View.VISIBLE);
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download));
} else {
installButton.setVisibility(View.GONE);
}
}
}
private void onDownloadProgressUpdated(int bytesRead, int totalBytes) {
if (installButton != null) {
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
int progressAsDegrees = totalBytes <= 0 ? 0 : (int) (((float) bytesRead / totalBytes) * 360);
installButton.setImageLevel(progressAsDegrees);
if (installedVersion != null) {
installedVersion.setVisibility(View.VISIBLE);
installedVersion.setText(viewState.getInstalledVersionText());
}
if (progressBar != null) {
progressBar.setVisibility(View.VISIBLE);
if (totalBytes <= 0) {
progressBar.setIndeterminate(true);
if (status != null) {
CharSequence statusText = viewState.getStatusText();
if (statusText == null) {
status.setVisibility(View.GONE);
} else {
progressBar.setIndeterminate(false);
progressBar.setMax(totalBytes);
progressBar.setProgress(bytesRead);
status.setVisibility(View.VISIBLE);
status.setText(statusText);
}
}
if (cancelButton != null) {
cancelButton.setVisibility(View.VISIBLE);
if (ignoredStatus != null) {
CharSequence ignoredStatusText = viewState.getIgnoredStatusText();
if (ignoredStatusText == null) {
ignoredStatus.setVisibility(View.GONE);
} else {
ignoredStatus.setVisibility(View.VISIBLE);
ignoredStatus.setText(ignoredStatusText);
}
}
}
private void onDownloadComplete() {
if (installButton != null) {
installButton.setVisibility(View.GONE);
private AppListItemState getViewStateInstalled(@NonNull App app) {
CharSequence mainText = activity.getString(
R.string.app_list__name__successfully_installed, app.name);
AppListItemState state = new AppListItemState(activity, app).setMainText(mainText);
if (activity.getPackageManager().getLaunchIntentForPackage(app.packageName) != null) {
state.showActionButton(activity.getString(R.string.menu_launch));
}
if (progressBar != null) {
progressBar.setVisibility(View.GONE);
}
return state;
}
if (cancelButton != null) {
cancelButton.setVisibility(View.GONE);
}
private AppListItemState getViewStateDownloading(
@NonNull App app, @NonNull AppUpdateStatusManager.AppUpdateStatus currentStatus) {
CharSequence mainText = activity.getString(
R.string.app_list__name__downloading_in_progress, app.name);
if (currentApp != null) {
configureActionButton(currentApp);
}
return new AppListItemState(activity, app)
.setMainText(mainText)
.setProgress(currentStatus.progressCurrent, currentStatus.progressMax);
}
private AppListItemState getViewStateReadyToInstall(@NonNull App app) {
int actionButtonLabel = app.isInstalled()
? R.string.app__install_downloaded_update
: R.string.menu_install;
return new AppListItemState(activity, app)
.setMainText(app.name)
.showActionButton(activity.getString(actionButtonLabel))
.setShowDownloadReady();
}
private AppListItemState getViewStateDefault(@NonNull App app) {
return new AppListItemState(activity, app);
}
@SuppressWarnings("FieldCanBeLocal")
@ -411,29 +348,9 @@ public class AppListItemController extends RecyclerView.ViewHolder {
* Updates both the progress bar and the circular install button (which shows progress around the outside of the circle).
* Also updates the app label to indicate that the app is being downloaded.
*/
private void updateAppStatus(@NonNull App app, @NonNull AppUpdateStatusManager.AppUpdateStatus status) {
private void updateAppStatus(@NonNull App app, @Nullable AppUpdateStatusManager.AppUpdateStatus status) {
currentStatus = status;
configureAppName(app);
configureActionButton(app);
switch (status.status) {
case Downloading:
onDownloadProgressUpdated(status.progressCurrent, status.progressMax);
break;
case ReadyToInstall:
onDownloadComplete();
break;
case Installed:
case Installing:
case InstallError:
case UpdateAvailable:
case DownloadInterrupted:
break;
}
refreshView(app, status);
}
private final BroadcastReceiver onStatusChanged = new BroadcastReceiver() {
@ -449,34 +366,6 @@ public class AppListItemController extends RecyclerView.ViewHolder {
}
};
private final BroadcastReceiver onInstallAction = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
if (currentApp == null || !TextUtils.equals(apk.packageName, currentApp.packageName)) {
return;
}
configureAppName(currentApp);
configureActionButton(currentApp);
if (installButton == null) {
return;
}
if (Installer.ACTION_INSTALL_STARTED.equals(intent.getAction())) {
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download_progress));
installButton.setImageLevel(0);
} else if (Installer.ACTION_INSTALL_COMPLETE.equals(intent.getAction())) {
installButton.setVisibility(View.GONE);
// TODO: It could've been a different version other than the current suggested version.
// In these cases, don't hide the button but rather set it back to the default install image.
} else if (Installer.ACTION_INSTALL_INTERRUPTED.equals(intent.getAction())) {
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download));
}
}
};
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onActionClicked = new View.OnClickListener() {
@Override

View File

@ -0,0 +1,141 @@
package org.fdroid.fdroid.views.apps;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.View;
import android.widget.TextView;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppPrefs;
public class AppListItemState {
private final Context context;
private final App app;
private CharSequence mainText = null;
private boolean showDownloadReady = false;
private CharSequence actionButtonText = null;
private int progressCurrent = -1;
private int progressMax = -1;
public AppListItemState(Context context, @NonNull App app) {
this.app = app;
this.context = context;
}
public AppListItemState setMainText(@NonNull CharSequence mainText) {
this.mainText = mainText;
return this;
}
public AppListItemState setShowDownloadReady() {
this.showDownloadReady = true;
return this;
}
public AppListItemState showActionButton(@NonNull CharSequence label) {
actionButtonText = label;
return this;
}
public AppListItemState setProgress(int progressCurrent, int progressMax) {
this.progressCurrent = progressCurrent;
this.progressMax = progressMax;
return this;
}
@Nullable
public CharSequence getMainText() {
return mainText != null
? mainText
: Utils.formatAppNameAndSummary(app.name, app.summary);
}
public boolean shouldShowInstall() {
boolean installable = app.canAndWantToUpdate(context) || !app.isInstalled();
boolean shouldAllow = app.compatible && !app.isFiltered();
return installable && shouldAllow && !shouldShowActionButton() && !showProgress();
}
public boolean shouldShowDownloadReady() {
return showDownloadReady;
}
public boolean shouldShowActionButton() {
return actionButtonText != null;
}
public CharSequence getActionButtonText() {
return actionButtonText;
}
public boolean showProgress() {
return progressCurrent >= 0;
}
public boolean isProgressIndeterminate() {
return progressMax <= 0;
}
public int getProgressCurrent() {
return progressCurrent;
}
public int getProgressMax() {
return progressMax;
}
/**
* 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
*/
@Nullable
public CharSequence getStatusText() {
String statusText = null;
if (!app.compatible) {
statusText = context.getString(R.string.app_incompatible);
} else if (app.isInstalled()) {
if (app.canAndWantToUpdate(context)) {
statusText = context.getString(R.string.app_version_x_available, app.getSuggestedVersionName());
} else {
statusText = context.getString(R.string.app_version_x_installed, app.installedVersionName);
}
}
return statusText;
}
/**
* Shows the currently installed version name, and whether or not it is the recommended version.
*/
public CharSequence getInstalledVersionText() {
int res = (app.suggestedVersionCode == app.installedVersionCode)
? R.string.app_recommended_version_installed
: R.string.app_version_x_installed;
return context.getString(res, app.installedVersionName);
}
/**
* Shows whether the user has previously asked to ignore updates for this app entirely, or for a
* specific version of this app. Binds to the {@link R.id#ignored_status} {@link TextView}.
*/
@Nullable
public CharSequence getIgnoredStatusText() {
AppPrefs prefs = app.getPrefs(context);
if (prefs.ignoreAllUpdates) {
return context.getString(R.string.installed_app__updates_ignored);
} else if (prefs.ignoreThisUpdate > 0 && prefs.ignoreThisUpdate == app.suggestedVersionCode) {
return context.getString(
R.string.installed_app__updates_ignored_for_suggested_version,
app.getSuggestedVersionName());
} else {
return null;
}
}
}