Java tutorial
/* * Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com * Copyright (C) 2013 Stefan Vlkel, bd@bc-bd.org * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package id.ridon.keude; import android.app.Activity; import android.app.AlertDialog; import android.app.ProgressDialog; import android.bluetooth.BluetoothAdapter; import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.Signature; import android.database.ContentObserver; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.support.v4.app.Fragment; import android.support.v4.app.ListFragment; import android.support.v4.app.NavUtils; import android.support.v4.view.MenuItemCompat; import android.support.v7.app.ActionBarActivity; import android.text.Html; import android.text.Spanned; import android.text.format.DateFormat; import android.text.method.LinkMovementMethod; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.widget.ArrayAdapter; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.assist.ImageScaleType; import id.ridon.keude.Utils.CommaSeparatedList; import id.ridon.keude.compat.PackageManagerCompat; import id.ridon.keude.data.Apk; import id.ridon.keude.data.ApkProvider; import id.ridon.keude.data.App; import id.ridon.keude.data.AppProvider; import id.ridon.keude.data.InstalledAppProvider; import id.ridon.keude.data.Repo; import id.ridon.keude.data.RepoProvider; import id.ridon.keude.installer.Installer; import id.ridon.keude.installer.Installer.AndroidNotCompatibleException; import id.ridon.keude.installer.Installer.InstallerCallback; import id.ridon.keude.net.ApkDownloader; import id.ridon.keude.net.Downloader; import java.io.File; import java.security.NoSuchAlgorithmException; import java.util.Iterator; import java.util.List; interface AppDetailsData { public App getApp(); public AppDetails.ApkListAdapter getApks(); public Signature getInstalledSignature(); public String getInstalledSignatureId(); } /** * Interface which allows the apk list fragment to communicate with the activity when * a user requests to install/remove an apk by clicking on an item in the list. * * NOTE: This is <em>not</em> to do with with the sudo/packagemanager/other installer * stuff which allows multiple ways to install apps. It is only here to make fragment- * activity communication possible. */ interface AppInstallListener { public void install(final Apk apk); public void removeApk(String packageName); } public class AppDetails extends ActionBarActivity implements ProgressListener, AppDetailsData, AppInstallListener { private static final String TAG = "id.ridon.keude.AppDetails"; public static final int REQUEST_ENABLE_BLUETOOTH = 2; public static final String EXTRA_APPID = "appid"; public static final String EXTRA_FROM = "from"; private KeudeApp fdroidApp; private ApkListAdapter adapter; private ProgressDialog progressDialog; private static class ViewHolder { TextView version; TextView status; TextView size; TextView api; TextView incompatibleReasons; TextView buildtype; TextView added; TextView nativecode; } // observer to update view when package has been installed/deleted AppObserver myAppObserver; class AppObserver extends ContentObserver { public AppObserver(Handler handler) { super(handler); } @Override public void onChange(boolean selfChange) { onChange(selfChange, null); } @Override public void onChange(boolean selfChange, Uri uri) { onChange(); } public void onChange() { if (!reset(app.id)) { AppDetails.this.finish(); return; } refreshApkList(); supportInvalidateOptionsMenu(); } } class ApkListAdapter extends ArrayAdapter<Apk> { private LayoutInflater mInflater = (LayoutInflater) mctx.getSystemService(Context.LAYOUT_INFLATER_SERVICE); public ApkListAdapter(Context context, App app) { super(context, 0); final List<Apk> apks = ApkProvider.Helper.findByApp(context, app.id); for (final Apk apk : apks) { if (apk.compatible || Preferences.get().showIncompatibleVersions()) { add(apk); } } } private String getInstalledStatus(final Apk apk) { // Definitely not installed. if (apk.vercode != app.installedVersionCode) { return getString(R.string.not_inst); } // Definitely installed this version. if (mInstalledSigID != null && apk.sig != null && apk.sig.equals(mInstalledSigID)) { return getString(R.string.inst); } // Installed the same version, but from someplace else. final String installerPkgName = mPm.getInstallerPackageName(app.id); if (installerPkgName != null && installerPkgName.length() > 0) { final String installerLabel = InstalledAppProvider.getApplicationLabel(mctx, installerPkgName); return getString(R.string.inst_known_source, installerLabel); } return getString(R.string.inst_unknown_source); } @Override public View getView(int position, View convertView, ViewGroup parent) { java.text.DateFormat df = DateFormat.getDateFormat(mctx); final Apk apk = getItem(position); ViewHolder holder; if (convertView == null) { convertView = mInflater.inflate(R.layout.apklistitem, parent, false); holder = new ViewHolder(); holder.version = (TextView) convertView.findViewById(R.id.version); holder.status = (TextView) convertView.findViewById(R.id.status); holder.size = (TextView) convertView.findViewById(R.id.size); holder.api = (TextView) convertView.findViewById(R.id.api); holder.incompatibleReasons = (TextView) convertView.findViewById(R.id.incompatible_reasons); holder.buildtype = (TextView) convertView.findViewById(R.id.buildtype); holder.added = (TextView) convertView.findViewById(R.id.added); holder.nativecode = (TextView) convertView.findViewById(R.id.nativecode); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } holder.version.setText(getString(R.string.version) + " " + apk.version + (apk.vercode == app.suggestedVercode ? " " : "")); holder.status.setText(getInstalledStatus(apk)); if (apk.size > 0) { holder.size.setText(Utils.getFriendlySize(apk.size)); holder.size.setVisibility(View.VISIBLE); } else { holder.size.setVisibility(View.GONE); } if (!Preferences.get().expertMode()) { holder.api.setVisibility(View.GONE); } else if (apk.minSdkVersion > 0 && apk.maxSdkVersion > 0) { holder.api.setText( getString(R.string.minsdk_up_to_maxsdk, Utils.getAndroidVersionName(apk.minSdkVersion), Utils.getAndroidVersionName(apk.maxSdkVersion))); holder.api.setVisibility(View.VISIBLE); } else if (apk.minSdkVersion > 0) { holder.api.setText( getString(R.string.minsdk_or_later, Utils.getAndroidVersionName(apk.minSdkVersion))); holder.api.setVisibility(View.VISIBLE); } else if (apk.maxSdkVersion > 0) { holder.api .setText(getString(R.string.up_to_maxsdk, Utils.getAndroidVersionName(apk.maxSdkVersion))); holder.api.setVisibility(View.VISIBLE); } if (apk.srcname != null) { holder.buildtype.setText("source"); } else { holder.buildtype.setText("bin"); } if (apk.added != null) { holder.added.setText(getString(R.string.added_on, df.format(apk.added))); holder.added.setVisibility(View.VISIBLE); } else { holder.added.setVisibility(View.GONE); } if (Preferences.get().expertMode() && apk.nativecode != null) { holder.nativecode.setText(apk.nativecode.toString().replaceAll(",", " ")); holder.nativecode.setVisibility(View.VISIBLE); } else { holder.nativecode.setVisibility(View.GONE); } if (apk.incompatible_reasons != null) { holder.incompatibleReasons.setText(getResources().getString(R.string.requires_features, apk.incompatible_reasons.toPrettyString())); holder.incompatibleReasons.setVisibility(View.VISIBLE); } else { holder.incompatibleReasons.setVisibility(View.GONE); } // Disable it all if it isn't compatible... View[] views = { convertView, holder.version, holder.status, holder.size, holder.api, holder.buildtype, holder.added, holder.nativecode }; for (View v : views) { v.setEnabled(apk.compatible); } return convertView; } } private static final int INSTALL = Menu.FIRST; private static final int UNINSTALL = Menu.FIRST + 1; private static final int IGNOREALL = Menu.FIRST + 2; private static final int IGNORETHIS = Menu.FIRST + 3; private static final int WEBSITE = Menu.FIRST + 4; private static final int ISSUES = Menu.FIRST + 5; private static final int SOURCE = Menu.FIRST + 6; private static final int LAUNCH = Menu.FIRST + 7; private static final int SHARE = Menu.FIRST + 8; private static final int DONATE = Menu.FIRST + 9; private static final int BITCOIN = Menu.FIRST + 10; private static final int LITECOIN = Menu.FIRST + 11; private static final int DOGECOIN = Menu.FIRST + 12; private static final int FLATTR = Menu.FIRST + 13; private static final int DONATE_URL = Menu.FIRST + 14; private static final int SEND_VIA_BLUETOOTH = Menu.FIRST + 15; private App app; private PackageManager mPm; private ApkDownloader downloadHandler; private boolean startingIgnoreAll; private int startingIgnoreThis; private final Context mctx = this; private Installer installer; /** * Stores relevant data that we want to keep track of when destroying the activity * with the expectation of it being recreated straight away (e.g. after an * orientation change). One of the major things is that we want the download thread * to stay active, but for it not to trigger any UI stuff (e.g. progress dialogs) * between the activity being destroyed and recreated. */ private static class ConfigurationChangeHelper { public ApkDownloader downloader; public App app; public ConfigurationChangeHelper(ApkDownloader downloader, App app) { this.downloader = downloader; this.app = app; } } private boolean inProcessOfChangingConfiguration = false; /** * Attempt to extract the appId from the intent which launched this activity. * Various different intents could cause us to show this activity, such as: * <ul> * <li>market://details?id=[app_id]</li> * <li>https://f-droid.org/app/[app_id]</li> * <li>fdroid.app:[app_id]</li> * </ul> * @return May return null, if we couldn't find the appId. In this case, you will * probably want to do something drastic like finish the activity and show some * feedback to the user (this method will <em>not</em> do that, it will just return * null). */ private String getAppIdFromIntent() { Intent i = getIntent(); Uri data = i.getData(); String appId = null; if (data != null) { if (data.isHierarchical()) { if (data.getHost() != null && data.getHost().equals("details")) { // market://details?id=app.id appId = data.getQueryParameter("id"); } else { // https://f-droid.org/app/app.id appId = data.getLastPathSegment(); if (appId != null && appId.equals("app")) { appId = null; } } } else { // fdroid.app:app.id appId = data.getEncodedSchemeSpecificPart(); } Log.d(TAG, "AppDetails launched from link, for '" + appId + "'"); } else if (!i.hasExtra(EXTRA_APPID)) { Log.e(TAG, "No application ID in AppDetails!?"); } else { appId = i.getStringExtra(EXTRA_APPID); } return appId; } @Override protected void onCreate(Bundle savedInstanceState) { fdroidApp = ((KeudeApp) getApplication()); fdroidApp.applyTheme(this); super.onCreate(savedInstanceState); // Must be called *after* super.onCreate(), as that is where the action bar // compat implementation is assigned in the ActionBarActivity base class. supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); if (getIntent().hasExtra(EXTRA_FROM)) { setTitle(getIntent().getStringExtra(EXTRA_FROM)); } mPm = getPackageManager(); installer = Installer.getActivityInstaller(this, mPm, myInstallerCallback); // Get the preferences we're going to use in this Activity... ConfigurationChangeHelper previousData = (ConfigurationChangeHelper) getLastCustomNonConfigurationInstance(); if (previousData != null) { Log.d(TAG, "Recreating view after configuration change."); downloadHandler = previousData.downloader; if (downloadHandler != null) { Log.d(TAG, "Download was in progress before the configuration change, so we will start to listen to its events again."); } app = previousData.app; setApp(app); } else { if (!reset(getAppIdFromIntent())) { finish(); return; } } // Set up the list... adapter = new ApkListAdapter(this, app); // Wait until all other intialization before doing this, because it will create the // fragments, which rely on data from the activity that is set earlier in this method. setContentView(R.layout.app_details); getSupportActionBar().setDisplayHomeAsUpEnabled(true); // Check for the presence of a view which only exists in the landscape view. // This seems to be the preferred way to interrogate the view, rather than // to check the orientation. I guess this is because views can be dynamically // chosen based on more than just orientation (e.g. large screen sizes). View onlyInLandscape = findViewById(R.id.app_summary_container); AppDetailsListFragment listFragment = (AppDetailsListFragment) getSupportFragmentManager() .findFragmentById(R.id.fragment_app_list); if (onlyInLandscape == null) { listFragment.setupSummaryHeader(); } else { listFragment.removeSummaryHeader(); } // Spinner seems to default to visible on Android 4.0.3 and 4.0.4 // https://gitlab.com/fdroid/fdroidclient/issues/75 // Can't put this in onResume(), because that is called on return from asking // the user permission to use su (in which case we still want to show the // progress indicator after returning from that prompt). setSupportProgressBarIndeterminateVisibility(false); } // The signature of the installed version. private Signature mInstalledSignature; private String mInstalledSigID; @Override protected void onResume() { super.onResume(); // register observer to know when install status changes myAppObserver = new AppObserver(new Handler()); getContentResolver().registerContentObserver(AppProvider.getContentUri(app.id), true, myAppObserver); if (downloadHandler != null) { if (downloadHandler.isComplete()) { downloadCompleteInstallApk(); } else { downloadHandler.setProgressListener(this); // Show the progress dialog, if for no other reason than to prevent them attempting // to download again (i.e. we force them to touch 'cancel' before they can access // the rest of the activity). Log.d(TAG, "Showing dialog to user after resuming app details view, because a download was previously in progress"); updateProgressDialog(); } } } @Override protected void onResumeFragments() { super.onResumeFragments(); refreshApkList(); supportInvalidateOptionsMenu(); } /** * Remove progress listener, suppress progress dialog, set downloadHandler to null. */ private void cleanUpFinishedDownload() { if (downloadHandler != null) { downloadHandler.removeProgressListener(); removeProgressDialog(); downloadHandler = null; } } /** * Once the download completes successfully, call this method to start the install process * with the file that was downloaded. */ private void downloadCompleteInstallApk() { if (downloadHandler != null) { installApk(downloadHandler.localFile(), downloadHandler.getApk().id); cleanUpFinishedDownload(); } } @Override protected void onPause() { if (myAppObserver != null) { getContentResolver().unregisterContentObserver(myAppObserver); } if (app != null && (app.ignoreAllUpdates != startingIgnoreAll || app.ignoreThisUpdate != startingIgnoreThis)) { Log.d(TAG, "Updating 'ignore updates', as it has changed since we started the activity..."); setIgnoreUpdates(app.id, app.ignoreAllUpdates, app.ignoreThisUpdate); } if (downloadHandler != null) { downloadHandler.removeProgressListener(); } removeProgressDialog(); super.onPause(); } public void setIgnoreUpdates(String appId, boolean ignoreAll, int ignoreVersionCode) { Uri uri = AppProvider.getContentUri(appId); ContentValues values = new ContentValues(2); values.put(AppProvider.DataColumns.IGNORE_ALLUPDATES, ignoreAll ? 1 : 0); values.put(AppProvider.DataColumns.IGNORE_THISUPDATE, ignoreVersionCode); getContentResolver().update(uri, values, null, null); } @Override public Object onRetainCustomNonConfigurationInstance() { inProcessOfChangingConfiguration = true; return new ConfigurationChangeHelper(downloadHandler, app); } @Override protected void onDestroy() { if (downloadHandler != null) { if (!inProcessOfChangingConfiguration) { downloadHandler.cancel(); cleanUpFinishedDownload(); } } inProcessOfChangingConfiguration = false; super.onDestroy(); } private void removeProgressDialog() { if (progressDialog != null) { progressDialog.dismiss(); progressDialog = null; } } // Reset the display and list contents. Used when entering the activity, and // also when something has been installed/uninstalled. // Return true if the app was found, false otherwise. private boolean reset(String appId) { Log.d(TAG, "Getting application details for " + appId); App newApp = null; if (appId != null && appId.length() > 0) { newApp = AppProvider.Helper.findById(getContentResolver(), appId); } setApp(newApp); return this.app != null; } /** * If passed null, this will show a message to the user ("Could not find app ..." or something * like that) and then finish the activity. */ private void setApp(App newApp) { if (newApp == null) { Toast.makeText(this, getString(R.string.no_such_app), Toast.LENGTH_LONG).show(); finish(); return; } app = newApp; startingIgnoreAll = app.ignoreAllUpdates; startingIgnoreThis = app.ignoreThisUpdate; // Get the signature of the installed package... mInstalledSignature = null; mInstalledSigID = null; if (app.isInstalled()) { PackageManager pm = getPackageManager(); try { PackageInfo pi = pm.getPackageInfo(app.id, PackageManager.GET_SIGNATURES); mInstalledSignature = pi.signatures[0]; Hasher hash = new Hasher("MD5", mInstalledSignature.toCharsString().getBytes()); mInstalledSigID = hash.getHash(); } catch (NameNotFoundException e) { Log.d(TAG, "Failed to get installed signature"); } catch (NoSuchAlgorithmException e) { Log.d(TAG, "Failed to calculate signature MD5 sum"); mInstalledSignature = null; } } } private void refreshApkList() { adapter.notifyDataSetChanged(); } @Override public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); menu.clear(); if (app == null) return true; if (app.canAndWantToUpdate()) { MenuItemCompat.setShowAsAction( menu.add(Menu.NONE, INSTALL, 0, R.string.menu_upgrade).setIcon(R.drawable.ic_menu_refresh), MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); } // Check count > 0 due to incompatible apps resulting in an empty list. if (!app.isInstalled() && app.suggestedVercode > 0 && adapter.getCount() > 0) { MenuItemCompat.setShowAsAction( menu.add(Menu.NONE, INSTALL, 1, R.string.menu_install).setIcon(android.R.drawable.ic_menu_add), MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); } else if (app.isInstalled()) { MenuItemCompat.setShowAsAction( menu.add(Menu.NONE, UNINSTALL, 1, R.string.menu_uninstall) .setIcon(android.R.drawable.ic_menu_delete), MenuItemCompat.SHOW_AS_ACTION_IF_ROOM | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); if (mPm.getLaunchIntentForPackage(app.id) != null) { MenuItemCompat.setShowAsAction( menu.add(Menu.NONE, LAUNCH, 1, R.string.menu_launch) .setIcon(android.R.drawable.ic_media_play), MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); } } MenuItemCompat.setShowAsAction( menu.add(Menu.NONE, SHARE, 1, R.string.menu_share).setIcon(android.R.drawable.ic_menu_share), MenuItemCompat.SHOW_AS_ACTION_IF_ROOM | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); menu.add(Menu.NONE, IGNOREALL, 2, R.string.menu_ignore_all) .setIcon(android.R.drawable.ic_menu_close_clear_cancel).setCheckable(true) .setChecked(app.ignoreAllUpdates); if (app.hasUpdates()) { menu.add(Menu.NONE, IGNORETHIS, 2, R.string.menu_ignore_this) .setIcon(android.R.drawable.ic_menu_close_clear_cancel).setCheckable(true) .setChecked(app.ignoreThisUpdate >= app.suggestedVercode); } if (app.webURL.length() > 0) { menu.add(Menu.NONE, WEBSITE, 3, R.string.menu_website).setIcon(android.R.drawable.ic_menu_view); } if (app.trackerURL.length() > 0) { menu.add(Menu.NONE, ISSUES, 4, R.string.menu_issues).setIcon(android.R.drawable.ic_menu_view); } if (app.sourceURL.length() > 0) { menu.add(Menu.NONE, SOURCE, 5, R.string.menu_source).setIcon(android.R.drawable.ic_menu_view); } if (app.bitcoinAddr != null || app.litecoinAddr != null || app.dogecoinAddr != null || app.flattrID != null || app.donateURL != null) { SubMenu donate = menu.addSubMenu(Menu.NONE, DONATE, 7, R.string.menu_donate) .setIcon(android.R.drawable.ic_menu_send); if (app.bitcoinAddr != null) donate.add(Menu.NONE, BITCOIN, 8, R.string.menu_bitcoin); if (app.litecoinAddr != null) donate.add(Menu.NONE, LITECOIN, 8, R.string.menu_litecoin); if (app.dogecoinAddr != null) donate.add(Menu.NONE, DOGECOIN, 8, R.string.menu_dogecoin); if (app.flattrID != null) donate.add(Menu.NONE, FLATTR, 9, R.string.menu_flattr); if (app.donateURL != null) donate.add(Menu.NONE, DONATE_URL, 10, R.string.menu_website); } if (app.isInstalled() && fdroidApp.bluetoothAdapter != null) { // ignore on devices without Bluetooth menu.add(Menu.NONE, SEND_VIA_BLUETOOTH, 6, R.string.send_via_bluetooth); } return true; } public void tryOpenUri(String s) { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(s)); if (intent.resolveActivity(getPackageManager()) == null) { Toast.makeText(this, getString(R.string.no_handler_app, intent.getDataString()), Toast.LENGTH_LONG) .show(); return; } startActivity(intent); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: NavUtils.navigateUpFromSameTask(this); return true; case LAUNCH: launchApk(app.id); return true; case SHARE: shareApp(app); return true; case INSTALL: // Note that this handles updating as well as installing. if (app.suggestedVercode > 0) { final Apk apkToInstall = ApkProvider.Helper.find(this, app.id, app.suggestedVercode); install(apkToInstall); } return true; case UNINSTALL: removeApk(app.id); return true; case IGNOREALL: app.ignoreAllUpdates ^= true; item.setChecked(app.ignoreAllUpdates); return true; case IGNORETHIS: if (app.ignoreThisUpdate >= app.suggestedVercode) app.ignoreThisUpdate = 0; else app.ignoreThisUpdate = app.suggestedVercode; item.setChecked(app.ignoreThisUpdate > 0); return true; case WEBSITE: tryOpenUri(app.webURL); return true; case ISSUES: tryOpenUri(app.trackerURL); return true; case SOURCE: tryOpenUri(app.sourceURL); return true; case BITCOIN: tryOpenUri("bitcoin:" + app.bitcoinAddr); return true; case LITECOIN: tryOpenUri("litecoin:" + app.litecoinAddr); return true; case DOGECOIN: tryOpenUri("dogecoin:" + app.dogecoinAddr); return true; case FLATTR: tryOpenUri("https://flattr.com/thing/" + app.flattrID); return true; case DONATE_URL: tryOpenUri(app.donateURL); return true; case SEND_VIA_BLUETOOTH: /* * If Bluetooth has not been enabled/turned on, then * enabling device discoverability will automatically enable Bluetooth */ Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 121); startActivityForResult(discoverBt, REQUEST_ENABLE_BLUETOOTH); // if this is successful, the Bluetooth transfer is started return true; } return super.onOptionsItemSelected(item); } // Install the version of this app denoted by 'app.curApk'. @Override public void install(final Apk apk) { String[] projection = { RepoProvider.DataColumns.ADDRESS }; Repo repo = RepoProvider.Helper.findById(this, apk.repo, projection); if (repo == null || repo.address == null) { return; } final String repoaddress = repo.address; if (!apk.compatible) { AlertDialog.Builder ask_alrt = new AlertDialog.Builder(this); ask_alrt.setMessage(getString(R.string.installIncompatible)); ask_alrt.setPositiveButton(getString(R.string.yes), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { startDownload(apk, repoaddress); } }); ask_alrt.setNegativeButton(getString(R.string.no), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { } }); AlertDialog alert = ask_alrt.create(); alert.show(); return; } if (mInstalledSigID != null && apk.sig != null && !apk.sig.equals(mInstalledSigID)) { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.SignatureMismatch).setPositiveButton(getString(R.string.ok), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int id) { dialog.cancel(); } }); AlertDialog alert = builder.create(); alert.show(); return; } startDownload(apk, repoaddress); } private void startDownload(Apk apk, String repoAddress) { downloadHandler = new ApkDownloader(apk, repoAddress, Utils.getApkCacheDir(getBaseContext())); downloadHandler.setProgressListener(this); if (downloadHandler.download()) { updateProgressDialog(); } } private void installApk(File file, String packageName) { setSupportProgressBarIndeterminateVisibility(true); try { installer.installPackage(file); } catch (AndroidNotCompatibleException e) { Log.e(TAG, "Android not compatible with this Installer!", e); setSupportProgressBarIndeterminateVisibility(false); } } @Override public void removeApk(String packageName) { setSupportProgressBarIndeterminateVisibility(true); try { installer.deletePackage(packageName); } catch (AndroidNotCompatibleException e) { Log.e(TAG, "Android not compatible with this Installer!", e); setSupportProgressBarIndeterminateVisibility(false); } } Installer.InstallerCallback myInstallerCallback = new Installer.InstallerCallback() { @Override public void onSuccess(final int operation) { runOnUiThread(new Runnable() { @Override public void run() { if (operation == Installer.InstallerCallback.OPERATION_INSTALL) { PackageManagerCompat.setInstaller(mPm, app.id); } setSupportProgressBarIndeterminateVisibility(false); myAppObserver.onChange(); } }); } @Override public void onError(int operation, final int errorCode) { if (errorCode == InstallerCallback.ERROR_CODE_CANCELED) { runOnUiThread(new Runnable() { @Override public void run() { setSupportProgressBarIndeterminateVisibility(false); myAppObserver.onChange(); } }); } else { runOnUiThread(new Runnable() { @Override public void run() { setSupportProgressBarIndeterminateVisibility(false); myAppObserver.onChange(); Log.e(TAG, "Installer aborted with errorCode: " + errorCode); AlertDialog.Builder alertBuilder = new AlertDialog.Builder(AppDetails.this); alertBuilder.setTitle(R.string.installer_error_title); alertBuilder.setMessage(R.string.installer_error_title); alertBuilder.setNeutralButton(android.R.string.ok, null); alertBuilder.create().show(); } }); } } }; private void launchApk(String id) { Intent intent = mPm.getLaunchIntentForPackage(id); startActivity(intent); } private void shareApp(App app) { Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(Intent.EXTRA_SUBJECT, app.name); shareIntent.putExtra(Intent.EXTRA_TEXT, app.name + " (" + app.summary + ") - https://f-droid.org/app/" + app.id); startActivity(Intent.createChooser(shareIntent, getString(R.string.menu_share))); } private ProgressDialog getProgressDialog(String file) { if (progressDialog == null) { final ProgressDialog pd = new ProgressDialog(this); pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); pd.setMessage(getString(R.string.download_server) + ":\n " + file); pd.setCancelable(true); pd.setCanceledOnTouchOutside(false); // The indeterminate-ness will get overridden on the first progress event we receive. pd.setIndeterminate(true); pd.setOnCancelListener(new DialogInterface.OnCancelListener() { @Override public void onCancel(DialogInterface dialog) { Log.d(TAG, "User clicked 'cancel' on download, attempting to interrupt download thread."); if (downloadHandler != null) { downloadHandler.cancel(); cleanUpFinishedDownload(); } else { Log.e(TAG, "Tried to cancel, but the downloadHandler doesn't exist."); } progressDialog = null; Toast.makeText(AppDetails.this, getString(R.string.download_cancelled), Toast.LENGTH_LONG) .show(); } }); pd.setButton(DialogInterface.BUTTON_NEUTRAL, getString(R.string.cancel), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { pd.cancel(); } }); progressDialog = pd; } return progressDialog; } /** * Looks at the current <code>downloadHandler</code> and finds it's size and progress. * This is in comparison to {@link id.ridon.keude.AppDetails#updateProgressDialog(int, int)}, * which is used when you have the details from a freshly received * {@link id.ridon.keude.ProgressListener.Event}. */ private void updateProgressDialog() { if (downloadHandler != null) { updateProgressDialog(downloadHandler.getProgress(), downloadHandler.getTotalSize()); } } private void updateProgressDialog(int progress, int total) { if (downloadHandler != null) { ProgressDialog pd = getProgressDialog(downloadHandler.getRemoteAddress()); if (total > 0) { pd.setIndeterminate(false); pd.setProgress(progress); pd.setMax(total); } else { pd.setIndeterminate(true); pd.setProgress(progress); pd.setMax(0); } if (!pd.isShowing()) { Log.d(TAG, "Showing progress dialog for download."); pd.show(); } } } @Override public void onProgress(Event event) { if (downloadHandler == null || !downloadHandler.isEventFromThis(event)) { // Choose not to respond to events from previous downloaders. // We don't even care if we receive "cancelled" events or the like, because // we dealt with cancellations in the onCancel listener of the dialog, // rather than waiting to receive the event here. We try and be careful in // the download thread to make sure that we check for cancellations before // sending events, but it is not possible to be perfect, because the interruption // which triggers the download can happen after the check to see if Log.d(TAG, "Discarding downloader event \"" + event.type + "\" as it is from an old (probably cancelled) downloader."); return; } boolean finished = false; if (event.type.equals(Downloader.EVENT_PROGRESS)) { updateProgressDialog(event.progress, event.total); } else if (event.type.equals(ApkDownloader.EVENT_ERROR)) { final String text; if (event.getData().getInt(ApkDownloader.EVENT_DATA_ERROR_TYPE) == ApkDownloader.ERROR_HASH_MISMATCH) text = getString(R.string.corrupt_download); else text = getString(R.string.details_notinstalled); // this must be on the main UI thread Toast.makeText(this, text, Toast.LENGTH_LONG).show(); finished = true; } else if (event.type.equals(ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE)) { downloadCompleteInstallApk(); finished = true; } if (finished) { removeProgressDialog(); downloadHandler = null; } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { // handle cases for install manager first if (installer.handleOnActivityResult(requestCode, resultCode, data)) { return; } switch (requestCode) { case REQUEST_ENABLE_BLUETOOTH: fdroidApp.sendViaBluetooth(this, resultCode, app.id); break; } } @Override public App getApp() { return app; } @Override public ApkListAdapter getApks() { return adapter; } @Override public Signature getInstalledSignature() { return mInstalledSignature; } @Override public String getInstalledSignatureId() { return mInstalledSigID; } public static class AppDetailsSummaryFragment extends Fragment { protected final Preferences prefs; private AppDetailsData data; public AppDetailsSummaryFragment() { prefs = Preferences.get(); } @Override public void onAttach(Activity activity) { super.onAttach(activity); data = (AppDetailsData) activity; } protected App getApp() { return data.getApp(); } protected ApkListAdapter getApks() { return data.getApks(); } protected Signature getInstalledSignature() { return data.getInstalledSignature(); } protected String getInstalledSignatureId() { return data.getInstalledSignatureId(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); View summaryView = inflater.inflate(R.layout.app_details_summary, container, false); setupView(summaryView); return summaryView; } @Override public void onResume() { super.onResume(); updateViews(getView()); } private void setupView(View view) { TextView description = (TextView) view.findViewById(R.id.description); Spanned desc = Html.fromHtml(getApp().description, null, new Utils.HtmlTagHandler()); description.setMovementMethod(LinkMovementMethod.getInstance()); description.setText(desc.subSequence(0, desc.length() - 2)); TextView appIdView = (TextView) view.findViewById(R.id.appid); if (prefs.expertMode()) appIdView.setText(getApp().id); else appIdView.setVisibility(View.GONE); TextView summaryView = (TextView) view.findViewById(R.id.summary); summaryView.setText(getApp().summary); Apk curApk = null; for (int i = 0; i < getApks().getCount(); i++) { final Apk apk = getApks().getItem(i); if (apk.vercode == getApp().suggestedVercode) { curApk = apk; break; } } TextView permissionListView = (TextView) view.findViewById(R.id.permissions_list); TextView permissionHeader = (TextView) view.findViewById(R.id.permissions); boolean curApkCompatible = curApk != null && curApk.compatible; if (prefs.showPermissions() && !getApks().isEmpty() && (curApkCompatible || prefs.showIncompatibleVersions())) { CommaSeparatedList permsList = getApks().getItem(0).permissions; if (permsList == null) { permissionListView.setText(getString(R.string.no_permissions)); } else { Iterator<String> permissions = permsList.iterator(); StringBuilder sb = new StringBuilder(); while (permissions.hasNext()) { final String permissionName = permissions.next(); try { Permission permission = new Permission(getActivity(), permissionName); // TODO: Make this list RTL friendly sb.append("\t ").append(permission.getName()).append('\n'); } catch (NameNotFoundException e) { if (permissionName.equals("ACCESS_SUPERUSER")) { // TODO: i18n this string, but surely it is already translated somewhere? sb.append("\t Full permissions to all device features and storage\n"); } else { Log.e(TAG, "Permission not yet available: " + permissionName); } } } if (sb.length() > 0) sb.setLength(sb.length() - 1); permissionListView.setText(sb.toString()); } permissionHeader.setText(getString(R.string.permissions_for_long, getApks().getItem(0).version)); } else { permissionListView.setVisibility(View.GONE); permissionHeader.setVisibility(View.GONE); } TextView antiFeaturesView = (TextView) view.findViewById(R.id.antifeatures); if (getApp().antiFeatures != null) { StringBuilder sb = new StringBuilder(); for (String af : getApp().antiFeatures) { final String afdesc = descAntiFeature(af); if (afdesc != null) { sb.append("\t ").append(afdesc).append("\n"); } } if (sb.length() > 0) { sb.setLength(sb.length() - 1); antiFeaturesView.setText(sb.toString()); } else { antiFeaturesView.setVisibility(View.GONE); } } else { antiFeaturesView.setVisibility(View.GONE); } updateViews(view); } private String descAntiFeature(String af) { if (af.equals("Ads")) return getString(R.string.antiadslist); if (af.equals("Tracking")) return getString(R.string.antitracklist); if (af.equals("NonFreeNet")) return getString(R.string.antinonfreenetlist); if (af.equals("NonFreeAdd")) return getString(R.string.antinonfreeadlist); if (af.equals("NonFreeDep")) return getString(R.string.antinonfreedeplist); if (af.equals("UpstreamNonFree")) return getString(R.string.antiupstreamnonfreelist); return null; } public void updateViews(View view) { if (view == null) { Log.e(TAG, "AppDetailsSummaryFragment.updateViews(): view == null. Oops."); return; } TextView signatureView = (TextView) view.findViewById(R.id.signature); if (prefs.expertMode() && getInstalledSignature() != null) { signatureView.setVisibility(View.VISIBLE); signatureView.setText("Signed: " + getInstalledSignatureId()); } else { signatureView.setVisibility(View.GONE); } } } public static class AppDetailsHeaderFragment extends Fragment { private AppDetailsData data; protected final Preferences prefs; protected final DisplayImageOptions displayImageOptions; public AppDetailsHeaderFragment() { prefs = Preferences.get(); displayImageOptions = new DisplayImageOptions.Builder().cacheInMemory(true).cacheOnDisk(true) .imageScaleType(ImageScaleType.NONE).showImageOnLoading(R.drawable.ic_repo_app_default) .showImageForEmptyUri(R.drawable.ic_repo_app_default).bitmapConfig(Bitmap.Config.RGB_565) .build(); } private App getApp() { return data.getApp(); } private ApkListAdapter getApks() { return data.getApks(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.app_details_header, container, false); setupView(view); return view; } @Override public void onAttach(Activity activity) { super.onAttach(activity); data = (AppDetailsData) activity; } private void setupView(View view) { // Set the icon... ImageView iv = (ImageView) view.findViewById(R.id.icon); ImageLoader.getInstance().displayImage(getApp().iconUrl, iv, displayImageOptions); // Set the title and other header details... TextView tv = (TextView) view.findViewById(R.id.title); tv.setText(getApp().name); tv = (TextView) view.findViewById(R.id.license); tv.setText(getApp().license); if (getApp().categories != null) { tv = (TextView) view.findViewById(R.id.categories); tv.setText(getApp().categories.toString().replaceAll(",", ", ")); } updateViews(view); } @Override public void onResume() { super.onResume(); updateViews(getView()); } public void updateViews(View view) { TextView statusView = (TextView) view.findViewById(R.id.status); if (getApp().isInstalled()) { statusView.setText(getString(R.string.details_installed, getApp().installedVersionName)); NfcHelper.setAndroidBeam(getActivity(), getApp().id); } else { statusView.setText(getString(R.string.details_notinstalled)); NfcHelper.disableAndroidBeam(getActivity()); } } } public static class AppDetailsListFragment extends ListFragment { private final String SUMMARY_TAG = "summary"; private AppDetailsData data; private AppInstallListener installListener; private AppDetailsSummaryFragment summaryFragment = null; private FrameLayout headerView; @Override public void onAttach(Activity activity) { super.onAttach(activity); data = (AppDetailsData) activity; installListener = (AppInstallListener) activity; } protected void install(final Apk apk) { installListener.install(apk); } protected void remove() { installListener.removeApk(getApp().id); } protected App getApp() { return data.getApp(); } protected ApkListAdapter getApks() { return data.getApks(); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { // A bit of a hack, but we can't add the header view in setupSummaryHeader(), // due to the fact it needs to happen before setListAdapter(). Also, seeing // as we may never add a summary header (i.e. in landscape), this is probably // the last opportunity to set the list adapter. As such, we use the headerView // as a mechanism to optionally allow adding a header in the future. if (headerView == null) { headerView = new FrameLayout(getActivity().getApplicationContext()); headerView.setId(R.id.appDetailsSummaryHeader); } else { Fragment summaryFragment = getChildFragmentManager().findFragmentByTag(SUMMARY_TAG); if (summaryFragment != null) { getChildFragmentManager().beginTransaction().remove(summaryFragment).commit(); } } setListAdapter(null); getListView().addHeaderView(headerView); setListAdapter(getApks()); } @Override public void onResume() { super.onResume(); } @Override public void onListItemClick(ListView l, View v, int position, long id) { final Apk apk = getApks().getItem(position - l.getHeaderViewsCount()); if (getApp().installedVersionCode == apk.vercode) remove(); else if (getApp().installedVersionCode > apk.vercode) { AlertDialog.Builder ask_alrt = new AlertDialog.Builder(getActivity()); ask_alrt.setMessage(getString(R.string.installDowngrade)); ask_alrt.setPositiveButton(getString(R.string.yes), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { install(apk); } }); ask_alrt.setNegativeButton(getString(R.string.no), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { } }); AlertDialog alert = ask_alrt.create(); alert.show(); } else install(apk); } public void removeSummaryHeader() { Fragment summary = getChildFragmentManager().findFragmentByTag(SUMMARY_TAG); if (summary != null) { getChildFragmentManager().beginTransaction().remove(summary).commit(); headerView.removeAllViews(); headerView.setVisibility(View.GONE); summaryFragment = null; } } public void setupSummaryHeader() { Fragment fragment = getChildFragmentManager().findFragmentByTag(SUMMARY_TAG); if (fragment != null) { summaryFragment = (AppDetailsSummaryFragment) fragment; } else { summaryFragment = new AppDetailsSummaryFragment(); } getChildFragmentManager().beginTransaction().replace(headerView.getId(), summaryFragment, SUMMARY_TAG) .commit(); headerView.setVisibility(View.VISIBLE); } } }