fdroid-client/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java

589 lines
23 KiB
Java

package org.fdroid.fdroid.views.apps;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.graphics.Outline;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.util.Pair;
import android.support.v7.widget.RecyclerView;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewOutlineProvider;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.nostra13.universalimageloader.core.DisplayImageOptions;
import com.nostra13.universalimageloader.core.ImageLoader;
import org.fdroid.fdroid.AppUpdateStatusManager;
import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
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.installer.ApkCache;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.installer.Installer;
import org.fdroid.fdroid.installer.InstallerFactory;
import org.fdroid.fdroid.views.AppDetailsActivity;
import org.fdroid.fdroid.views.updates.UpdatesAdapter;
import java.io.File;
import java.util.Iterator;
import java.util.Set;
/**
* Supports the following layouts:
* <ul>
* <li>app_list_item (see {@link StandardAppListItemController}</li>
* <li>updateable_app_list_status_item (see
* {@link org.fdroid.fdroid.views.updates.items.AppStatusListItemController}</li>
* <li>updateable_app_list_item (see
* {@link org.fdroid.fdroid.views.updates.items.UpdateableAppListItemController}</li>
* <li>installed_app_list_item (see {@link StandardAppListItemController}</li>
* </ul>
* <p>
* The state of the UI is defined in a dumb {@link AppListItemState} class, then applied to the UI
* in the {@link #updateAppStatus(App, AppUpdateStatus)} method.
*/
public abstract class AppListItemController extends RecyclerView.ViewHolder {
private static final String TAG = "AppListItemController";
private static Preferences prefs;
protected final Activity activity;
@NonNull
private final ImageView icon;
@NonNull
private final TextView name;
@Nullable
private final ImageView installButton;
@Nullable
private final TextView status;
@Nullable
private final TextView secondaryStatus;
@Nullable
private final ProgressBar progressBar;
@Nullable
private final ImageButton cancelButton;
/**
* Will operate as the "Download is complete, click to (install|update)" button, as well as the
* "Installed successfully, click to run" button.
*/
@Nullable
private final Button actionButton;
@Nullable
private final Button secondaryButton;
@Nullable
private final CheckBox checkBox;
@Nullable
private App currentApp;
@Nullable
private AppUpdateStatus currentStatus;
@TargetApi(21)
public AppListItemController(final Activity activity, View itemView) {
super(itemView);
this.activity = activity;
if (prefs == null) {
prefs = Preferences.get();
}
installButton = (ImageView) itemView.findViewById(R.id.install);
if (installButton != null) {
installButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onActionButtonPressed(currentApp);
}
});
if (Build.VERSION.SDK_INT >= 21) {
installButton.setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
float density = activity.getResources().getDisplayMetrics().density;
// This is a bit hacky/hardcoded/too-specific to the particular icons we're using.
// This is because the default "download & install" and "downloaded & ready to install"
// icons are smaller than the "downloading progress" button. Hence, we can't just use
// the width/height of the view to calculate the outline size.
int xPadding = (int) (8 * density);
int yPadding = (int) (9 * density);
int right = installButton.getWidth() - xPadding;
int bottom = installButton.getHeight() - yPadding;
outline.setOval(xPadding, yPadding, right, bottom);
}
});
}
}
icon = (ImageView) itemView.findViewById(R.id.icon);
name = (TextView) itemView.findViewById(R.id.app_name);
status = (TextView) itemView.findViewById(R.id.status);
secondaryStatus = (TextView) itemView.findViewById(R.id.secondary_status);
progressBar = (ProgressBar) itemView.findViewById(R.id.progress_bar);
cancelButton = (ImageButton) itemView.findViewById(R.id.cancel_button);
actionButton = (Button) itemView.findViewById(R.id.action_button);
secondaryButton = (Button) itemView.findViewById(R.id.secondary_button);
checkBox = itemView.findViewById(R.id.checkbox);
if (actionButton != null) {
actionButton.setEnabled(true);
actionButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
actionButton.setEnabled(false);
onActionButtonPressed(currentApp);
}
});
}
if (secondaryButton != null) {
secondaryButton.setOnClickListener(onSecondaryButtonClicked);
}
if (cancelButton != null) {
cancelButton.setOnClickListener(onCancelDownload);
}
itemView.setOnClickListener(onAppClicked);
}
@Nullable
protected final AppUpdateStatus getCurrentStatus() {
return currentStatus;
}
public void bindModel(@NonNull App app) {
currentApp = app;
if (actionButton != null) actionButton.setEnabled(true);
if (app.iconUrl == null) {
try {
icon.setImageDrawable(activity.getPackageManager().getApplicationIcon(app.packageName));
} catch (PackageManager.NameNotFoundException e) {
DisplayImageOptions options = Utils.getRepoAppDisplayImageOptions();
icon.setImageDrawable(options.shouldShowImageForEmptyUri()
? options.getImageForEmptyUri(FDroidApp.getInstance().getResources())
: null);
}
} else {
ImageLoader.getInstance().displayImage(app.iconUrl, icon, Utils.getRepoAppDisplayImageOptions());
}
// Figures out the current install/update/download/etc status for the app we are viewing.
// Then, asks the view to update itself to reflect this status.
Iterator<AppUpdateStatus> statuses =
AppUpdateStatusManager.getInstance(activity).getByPackageName(app.packageName).iterator();
if (statuses.hasNext()) {
AppUpdateStatus status = statuses.next();
updateAppStatus(app, status);
} else {
updateAppStatus(app, null);
}
final LocalBroadcastManager broadcastManager =
LocalBroadcastManager.getInstance(activity.getApplicationContext());
broadcastManager.unregisterReceiver(onStatusChanged);
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);
}
/**
* To be overridden if required
*/
public boolean canDismiss() {
return false;
}
/**
* If able, forwards the request onto {@link #onDismissApp(App, UpdatesAdapter)}.
* This mainly exists to keep the API consistent, in that the {@link App} is threaded through to the relevant
* method with a guarantee that it is not null, rather than every method having to check if it is null or not.
*/
public final void onDismiss(UpdatesAdapter adapter) {
if (currentApp != null && canDismiss()) {
onDismissApp(currentApp, adapter);
}
}
/**
* Override to respond to the user swiping an app to dismiss it from the list.
*
* @param app The app that was swiped away
* @param updatesAdapter The adapter. Can be used for refreshing the adapter with adapter.refreshStatuses().
* @see #canDismiss() This must also be overridden and should return true.
*/
protected void onDismissApp(@NonNull App app, UpdatesAdapter updatesAdapter) {
}
/**
* 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.
* <p>
* Queries the current state via {@link #getCurrentViewState(App, AppUpdateStatus)}
* and then updates the relevant widgets depending on that state.
* <p>
* Should contain little to no business logic, this all belongs to
* {@link #getCurrentViewState(App, AppUpdateStatus)}.
*
* @see AppListItemState
* @see #getCurrentViewState(App, AppUpdateStatus)
*/
private void updateAppStatus(@NonNull App app, @Nullable AppUpdateStatus appStatus) {
currentStatus = appStatus;
AppListItemState viewState = getCurrentViewState(app, appStatus);
name.setText(viewState.getMainText());
if (actionButton != null) {
if (viewState.shouldShowActionButton()) {
actionButton.setVisibility(View.VISIBLE);
actionButton.setText(viewState.getActionButtonText());
} else {
actionButton.setVisibility(View.GONE);
}
}
if (secondaryButton != null) {
if (viewState.shouldShowSecondaryButton()) {
secondaryButton.setVisibility(View.VISIBLE);
secondaryButton.setText(viewState.getSecondaryButtonText());
} else {
secondaryButton.setVisibility(View.GONE);
}
}
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 {
progressBar.setVisibility(View.GONE);
}
}
if (cancelButton != null) {
if (viewState.showProgress()) {
cancelButton.setVisibility(View.VISIBLE);
} else {
cancelButton.setVisibility(View.GONE);
}
}
if (installButton != null) {
if (viewState.shouldShowActionButton()) {
installButton.setVisibility(View.GONE);
} else if (viewState.showProgress()) {
installButton.setEnabled(false);
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.setEnabled(true);
installButton.setVisibility(View.VISIBLE);
installButton.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.ic_download));
} else {
installButton.setVisibility(View.GONE);
}
}
if (status != null) {
CharSequence statusText = viewState.getStatusText();
if (statusText == null) {
status.setVisibility(View.GONE);
} else {
status.setVisibility(View.VISIBLE);
status.setText(statusText);
}
}
if (secondaryStatus != null) {
CharSequence statusText = viewState.getSecondaryStatusText();
if (statusText == null) {
secondaryStatus.setVisibility(View.GONE);
} else {
secondaryStatus.setVisibility(View.VISIBLE);
secondaryStatus.setText(statusText);
}
}
if (checkBox != null) {
if (viewState.shouldShowCheckBox()) {
itemView.setOnClickListener(selectInstalledAppListener);
checkBox.setChecked(viewState.isCheckBoxChecked());
checkBox.setVisibility(View.VISIBLE);
status.setVisibility(View.GONE);
secondaryStatus.setVisibility(View.GONE);
} else {
checkBox.setVisibility(View.GONE);
}
}
}
@NonNull
protected AppListItemState getCurrentViewState(@NonNull App app, @Nullable AppUpdateStatus appStatus) {
if (appStatus == null) {
return getViewStateDefault(app);
} else {
switch (appStatus.status) {
case ReadyToInstall:
return getViewStateReadyToInstall(app);
case PendingInstall:
case Downloading:
return getViewStateDownloading(app, appStatus);
case Installing:
return getViewStateInstalling(app);
case Installed:
return getViewStateInstalled(app);
default:
return getViewStateDefault(app);
}
}
}
protected AppListItemState getViewStateInstalling(@NonNull App app) {
CharSequence mainText = activity.getString(
R.string.app_list__name__downloading_in_progress, app.name);
return new AppListItemState(app)
.setMainText(mainText)
.showActionButton(null)
.setStatusText(activity.getString(R.string.notification_content_single_installing, app.name));
}
protected AppListItemState getViewStateInstalled(@NonNull App app) {
CharSequence mainText = activity.getString(
R.string.app_list__name__successfully_installed, app.name);
AppListItemState state = new AppListItemState(app)
.setMainText(mainText)
.setStatusText(activity.getString(R.string.notification_content_single_installed));
if (activity.getPackageManager().getLaunchIntentForPackage(app.packageName) != null) {
state.showActionButton(activity.getString(R.string.menu_launch));
}
return state;
}
protected AppListItemState getViewStateDownloading(@NonNull App app, @NonNull AppUpdateStatus currentStatus) {
CharSequence mainText = activity.getString(
R.string.app_list__name__downloading_in_progress, app.name);
return new AppListItemState(app)
.setMainText(mainText)
.setProgress(Utils.bytesToKb(currentStatus.progressCurrent),
Utils.bytesToKb(currentStatus.progressMax));
}
protected AppListItemState getViewStateReadyToInstall(@NonNull App app) {
int actionButtonLabel = app.isInstalled(activity.getApplicationContext())
? R.string.app__install_downloaded_update
: R.string.menu_install;
return new AppListItemState(app)
.setMainText(app.name)
.showActionButton(activity.getString(actionButtonLabel))
.setStatusText(activity.getString(R.string.app_list_download_ready));
}
protected AppListItemState getViewStateDefault(@NonNull App app) {
return new AppListItemState(app);
}
/* =================================================================
* Various listeners for each different click/broadcast that we need
* to respond to.
* =================================================================
*/
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onAppClicked = new View.OnClickListener() {
@Override
public void onClick(View v) {
if (currentApp == null) {
return;
}
Intent intent = new Intent(activity, AppDetailsActivity.class);
intent.putExtra(AppDetailsActivity.EXTRA_APPID, currentApp.packageName);
if (Build.VERSION.SDK_INT >= 21) {
String transitionAppIcon = activity.getString(R.string.transition_app_item_icon);
Pair<View, String> iconTransitionPair = Pair.create((View) icon, transitionAppIcon);
Bundle bundle = ActivityOptionsCompat
.makeSceneTransitionAnimation(activity, iconTransitionPair).toBundle();
activity.startActivity(intent, bundle);
} else {
activity.startActivity(intent);
}
}
};
private final BroadcastReceiver onStatusChanged = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
AppUpdateStatus newStatus = intent.getParcelableExtra(AppUpdateStatusManager.EXTRA_STATUS);
if (currentApp == null
|| !TextUtils.equals(newStatus.app.packageName, currentApp.packageName)
|| (installButton == null && progressBar == null)) {
return;
}
updateAppStatus(currentApp, newStatus);
}
};
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onSecondaryButtonClicked = new View.OnClickListener() {
@Override
public void onClick(View v) {
if (currentApp == null) {
return;
}
onSecondaryButtonPressed(currentApp);
}
};
protected void onActionButtonPressed(App app) {
if (app == null) {
return;
}
// When the button says "Open", then launch the app.
if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.Installed) {
Intent intent = activity.getPackageManager().getLaunchIntentForPackage(app.packageName);
if (intent != null) {
activity.startActivity(intent);
// Once it is explicitly launched by the user, then we can pretty much forget about
// any sort of notification that the app was successfully installed. It should be
// apparent to the user because they just launched it.
AppUpdateStatusManager.getInstance(activity).removeApk(currentStatus.getCanonicalUrl());
}
return;
}
if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) {
String canonicalUrl = currentStatus.apk.getCanonicalUrl();
File apkFilePath = ApkCache.getApkDownloadPath(activity, canonicalUrl);
Utils.debugLog(TAG, "skip download, we have already downloaded " + currentStatus.apk.getCanonicalUrl() +
" to " + apkFilePath);
final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(activity);
final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
broadcastManager.unregisterReceiver(this);
if (Installer.ACTION_INSTALL_USER_INTERACTION.equals(intent.getAction())) {
PendingIntent pendingIntent =
intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI);
try {
pendingIntent.send();
} catch (PendingIntent.CanceledException ignored) {
}
}
}
};
Uri canonicalUri = Uri.parse(canonicalUrl);
broadcastManager.registerReceiver(receiver, Installer.getInstallIntentFilter(canonicalUri));
Installer installer = InstallerFactory.create(activity, currentStatus.apk);
installer.installPackage(Uri.parse(apkFilePath.toURI().toString()), canonicalUri);
} else {
final Apk suggestedApk = ApkProvider.Helper.findSuggestedApk(activity, app);
InstallManagerService.queue(activity, app, suggestedApk);
}
}
/**
* To be overridden by subclasses if desired
*/
protected void onSecondaryButtonPressed(@NonNull App app) {
}
@SuppressWarnings("FieldCanBeLocal")
private final View.OnClickListener onCancelDownload = new View.OnClickListener() {
@Override
public void onClick(View v) {
cancelDownload();
}
};
protected final void cancelDownload() {
if (currentStatus == null || currentStatus.status != AppUpdateStatusManager.Status.Downloading) {
return;
}
InstallManagerService.cancel(activity, currentStatus.getCanonicalUrl());
}
private final View.OnClickListener selectInstalledAppListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
Set<String> wipeSet = prefs.getPanicTmpSelectedSet();
checkBox.toggle();
if (checkBox.isChecked()) {
wipeSet.add(currentApp.packageName);
} else {
wipeSet.remove(currentApp.packageName);
}
prefs.setPanicTmpSelectedSet(wipeSet);
}
};
}