package org.fdroid.fdroid.views.swap; import android.annotation.TargetApi; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.database.ContentObserver; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.support.annotation.ColorRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.LoaderManager; import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.MenuItemCompat; import android.support.v4.widget.CursorAdapter; import android.support.v7.widget.SearchView; import android.text.TextUtils; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageView; import android.widget.ListView; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import org.fdroid.fdroid.R; import org.fdroid.fdroid.UpdateService; 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.AppProvider; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.Schema.AppMetadataTable; import org.fdroid.fdroid.localrepo.SwapService; import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.DownloaderService; import java.util.Timer; import java.util.TimerTask; public class SwapAppsView extends ListView implements SwapWorkflowActivity.InnerView, LoaderManager.LoaderCallbacks<Cursor>, SearchView.OnQueryTextListener { private DisplayImageOptions displayImageOptions; public SwapAppsView(Context context) { super(context); } public SwapAppsView(Context context, AttributeSet attrs) { super(context, attrs); } public SwapAppsView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @TargetApi(21) public SwapAppsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } private SwapWorkflowActivity getActivity() { return (SwapWorkflowActivity) getContext(); } private static final int LOADER_SWAPABLE_APPS = 759283741; private static final String TAG = "SwapAppsView"; private Repo repo; private AppListAdapter adapter; private String currentFilterString; @Override protected void onFinishInflate() { super.onFinishInflate(); repo = getActivity().getState().getPeerRepo(); /* if (repo == null) { TODO: Uh oh, something stuffed up for this to happen. TODO: What is the best course of action from here? } */ adapter = new AppListAdapter(getContext(), getContext().getContentResolver().query( AppProvider.getRepoUri(repo), AppMetadataTable.Cols.ALL, null, null, null)); setAdapter(adapter); // either reconnect with an existing loader or start a new one getActivity().getSupportLoaderManager().initLoader(LOADER_SWAPABLE_APPS, null, this); displayImageOptions = Utils.getImageLoadingOptions().build(); LocalBroadcastManager.getInstance(getActivity()).registerReceiver( pollForUpdatesReceiver, new IntentFilter(UpdateService.LOCAL_ACTION_STATUS)); schedulePollForUpdates(); } /** * Remove relevant listeners/receivers/etc so that they do not receive and process events * when this view is not in use. */ @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(pollForUpdatesReceiver); } private void pollForUpdates() { if (adapter.getCount() > 1 || (adapter.getCount() == 1 && !new App((Cursor) adapter.getItem(0)).packageName.equals("org.fdroid.fdroid"))) { Utils.debugLog(TAG, "Not polling for new apps from swap repo, because we already have more than one."); return; } Utils.debugLog(TAG, "Polling swap repo to see if it has any updates."); getActivity().getService().refreshSwap(); } private void schedulePollForUpdates() { Utils.debugLog(TAG, "Scheduling poll for updated swap repo in 5 seconds."); new Timer().schedule(new TimerTask() { @Override public void run() { Looper.prepare(); pollForUpdates(); Looper.loop(); } }, 5000); } @Override public boolean buildMenu(Menu menu, @NonNull MenuInflater inflater) { inflater.inflate(R.menu.swap_search, menu); SearchView searchView = new SearchView(getActivity()); MenuItem searchMenuItem = menu.findItem(R.id.action_search); MenuItemCompat.setActionView(searchMenuItem, searchView); MenuItemCompat.setShowAsAction(searchMenuItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS); searchView.setOnQueryTextListener(this); return true; } @Override public int getStep() { return SwapService.STEP_SUCCESS; } @Override public int getPreviousStep() { return SwapService.STEP_INTRO; } @ColorRes public int getToolbarColour() { return R.color.swap_bright_blue; } @Override public String getToolbarTitle() { return getResources().getString(R.string.swap_success); } @Override public CursorLoader onCreateLoader(int id, Bundle args) { Uri uri = TextUtils.isEmpty(currentFilterString) ? AppProvider.getRepoUri(repo) : AppProvider.getSearchUri(repo, currentFilterString); return new CursorLoader(getActivity(), uri, AppMetadataTable.Cols.ALL, null, null, AppMetadataTable.Cols.NAME); } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { adapter.swapCursor(cursor); } @Override public void onLoaderReset(Loader<Cursor> loader) { adapter.swapCursor(null); } @Override public boolean onQueryTextChange(String newText) { String newFilter = !TextUtils.isEmpty(newText) ? newText : null; if (currentFilterString == null && newFilter == null) { return true; } if (currentFilterString != null && currentFilterString.equals(newFilter)) { return true; } currentFilterString = newFilter; getActivity().getSupportLoaderManager().restartLoader(LOADER_SWAPABLE_APPS, null, this); return true; } @Override public boolean onQueryTextSubmit(String query) { // this is not needed since we respond to every change in text return true; } private class AppListAdapter extends CursorAdapter { private class ViewHolder { private final LocalBroadcastManager localBroadcastManager; private App app; ProgressBar progressView; TextView nameView; ImageView iconView; Button btnInstall; TextView statusInstalled; TextView statusIncompatible; private final BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { switch (intent.getAction()) { case Downloader.ACTION_STARTED: resetView(); break; case Downloader.ACTION_PROGRESS: if (progressView.getVisibility() != View.VISIBLE) { showProgress(); } int read = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int total = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); if (total > 0) { int progress = (int) ((double) read / total * 100); progressView.setIndeterminate(false); progressView.setMax(100); progressView.setProgress(progress); } else { progressView.setIndeterminate(true); } break; case Downloader.ACTION_COMPLETE: resetView(); break; case Downloader.ACTION_INTERRUPTED: if (intent.hasExtra(Downloader.EXTRA_ERROR_MESSAGE)) { String msg = intent.getStringExtra(Downloader.EXTRA_ERROR_MESSAGE) + " " + intent.getDataString(); Toast.makeText(context, R.string.download_error, Toast.LENGTH_SHORT).show(); Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); } else { // user canceled Toast.makeText(context, R.string.details_notinstalled, Toast.LENGTH_LONG).show(); } resetView(); break; default: throw new RuntimeException("intent action not handled!"); } } }; private final ContentObserver appObserver = new ContentObserver(new Handler()) { @Override public void onChange(boolean selfChange) { Activity activity = getActivity(); if (activity != null) { app = AppProvider.Helper.findSpecificApp(getActivity().getContentResolver(), app.packageName, app.repoId, AppMetadataTable.Cols.ALL); resetView(); } } }; ViewHolder() { localBroadcastManager = LocalBroadcastManager.getInstance(getContext()); } public void setApp(@NonNull App app) { if (this.app == null || !this.app.packageName.equals(app.packageName)) { this.app = app; Context context = getContext(); Apk apk = ApkProvider.Helper.findApkFromAnyRepo(context, app.packageName, app.suggestedVersionCode); String urlString = apk.getUrl(); // TODO unregister receivers? or will they just die with this instance localBroadcastManager.registerReceiver(downloadReceiver, DownloaderService.getIntentFilter(urlString)); // NOTE: Instead of continually unregistering and re-registering the observer // (with a different URI), this could equally be done by only having one // registration in the constructor, and using the ContentObserver.onChange(boolean, URI) // method and inspecting the URI to see if it matches. However, this was only // implemented on API-16, so leaving like this for now. getActivity().getContentResolver().unregisterContentObserver(appObserver); getActivity().getContentResolver().registerContentObserver( AppProvider.getSpecificAppUri(this.app.packageName, this.app.repoId), true, appObserver); } resetView(); } private void resetView() { if (app == null) { return; } progressView.setVisibility(View.GONE); progressView.setIndeterminate(true); if (app.name != null) { nameView.setText(app.name); } ImageLoader.getInstance().displayImage(app.iconUrl, iconView, displayImageOptions); if (app.hasUpdates()) { btnInstall.setText(R.string.menu_upgrade); btnInstall.setVisibility(View.VISIBLE); statusIncompatible.setVisibility(View.GONE); statusInstalled.setVisibility(View.GONE); } else if (app.isInstalled()) { btnInstall.setVisibility(View.GONE); statusIncompatible.setVisibility(View.GONE); statusInstalled.setVisibility(View.VISIBLE); } else if (!app.compatible) { btnInstall.setVisibility(View.GONE); statusIncompatible.setVisibility(View.VISIBLE); statusInstalled.setVisibility(View.GONE); } else { btnInstall.setText(R.string.menu_install); btnInstall.setVisibility(View.VISIBLE); statusIncompatible.setVisibility(View.GONE); statusInstalled.setVisibility(View.GONE); } OnClickListener installListener = new OnClickListener() { @Override public void onClick(View v) { if (app.hasUpdates() || app.compatible) { getActivity().install(app); showProgress(); } } }; btnInstall.setOnClickListener(installListener); } private void showProgress() { progressView.setVisibility(View.VISIBLE); btnInstall.setVisibility(View.GONE); statusInstalled.setVisibility(View.GONE); statusIncompatible.setVisibility(View.GONE); } } @Nullable private LayoutInflater inflater; AppListAdapter(@NonNull Context context, @Nullable Cursor c) { super(context, c, FLAG_REGISTER_CONTENT_OBSERVER); } @NonNull private LayoutInflater getInflater(Context context) { if (inflater == null) { inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } return inflater; } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { View view = getInflater(context).inflate(R.layout.swap_app_list_item, parent, false); ViewHolder holder = new ViewHolder(); holder.progressView = (ProgressBar) view.findViewById(R.id.progress); holder.nameView = (TextView) view.findViewById(R.id.name); holder.iconView = (ImageView) view.findViewById(android.R.id.icon); holder.btnInstall = (Button) view.findViewById(R.id.btn_install); holder.statusInstalled = (TextView) view.findViewById(R.id.status_installed); holder.statusIncompatible = (TextView) view.findViewById(R.id.status_incompatible); view.setTag(holder); bindView(view, context, cursor); return view; } @Override public void bindView(final View view, final Context context, final Cursor cursor) { ViewHolder holder = (ViewHolder) view.getTag(); final App app = new App(cursor); holder.setApp(app); } } private final BroadcastReceiver pollForUpdatesReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { int statusCode = intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1); switch (statusCode) { case UpdateService.STATUS_COMPLETE_WITH_CHANGES: Utils.debugLog(TAG, "Swap repo has updates, notifying the list adapter."); getActivity().runOnUiThread(new Runnable() { @Override public void run() { adapter.notifyDataSetChanged(); } }); break; case UpdateService.STATUS_ERROR_GLOBAL: // TODO: Well, if we can't get the index, we probably can't swapp apps. // Tell the user something helpful? break; case UpdateService.STATUS_COMPLETE_AND_SAME: schedulePollForUpdates(); break; } } }; }