diff --git a/app/build.gradle b/app/build.gradle index 01ddd57ed..1af92e952 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,6 +21,9 @@ dependencies { compile 'com.android.support:support-v4:24.2.1' compile 'com.android.support:appcompat-v7:24.2.1' compile 'com.android.support:support-annotations:24.2.1' + compile 'com.android.support:design:24.2.1' + compile 'com.android.support:cardview-v7:24.2.1' + compile "com.android.support:recyclerview-v7:24.2.1" compile 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' compile 'com.google.zxing:core:3.2.1' @@ -41,6 +44,9 @@ dependencies { testCompile "org.robolectric:robolectric:3.1.2" + // As per https://github.com/robolectric/robolectric/issues/1932#issuecomment-219796474 + testCompile 'org.khronos:opengl-api:gl1.1-android-2.1_r1' + testCompile "org.mockito:mockito-core:1.10.19" androidTestCompile 'com.android.support:support-annotations:24.2.1' @@ -97,6 +103,9 @@ if (!hasProperty('sourceDeps')) { 'com.android.support:support-media-compat:fa29a23eadd685631584b2c0c624a36e3bb79a33e257b00304501ad682fa2be3', 'com.android.support:support-v4:cac2956f5c4bb363cc0ba824ac16ea2a687d1c305d434416a34772a5f9375ed7', 'com.android.support:support-vector-drawable:6ee37a7f7b93c1df1294e6f6f97df3724ac989fcda0549faf677001085330548', + 'com.android.support:design:89842bb1243507fe3079066ea4ea58795effe69cdf9a819e05274d21760adfc2', + 'com.android.support:cardview-v7:2303b351686d1db060b5fcf1a9c709c79b4a54a85bfda0fb3c4849e244606ee1', + 'com.android.support:recyclerview-v7:9077766a1a0f4e89528fbf9dcdf6d5880a8686f0266fa852d58d803beeef18fa', 'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259', 'com.madgag.spongycastle:core:9b6b7ac856b91bcda2ede694eccd26cefb0bf0b09b89f13cda05b5da5ff68c6b', 'com.madgag.spongycastle:pkix:6aba9b2210907a3d46dd3dcac782bb3424185290468d102d5207ebdc9796a905', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d927ae32..08c034216 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -404,6 +404,17 @@ android:value=".FDroid" /> + + + { - private final LayoutInflater mInflater = (LayoutInflater) context.getSystemService( + private final LayoutInflater inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE); ApkListAdapter(Context context, App app) { @@ -204,7 +204,7 @@ public class AppDetails extends AppCompatActivity { ViewHolder holder; if (convertView == null) { - convertView = mInflater.inflate(R.layout.apklistitem, parent, false); + convertView = inflater.inflate(R.layout.apklistitem, parent, false); holder = new ViewHolder(); holder.version = (TextView) convertView.findViewById(R.id.version); @@ -1157,7 +1157,7 @@ public class AppDetails extends AppCompatActivity { } }; - private final View.OnClickListener mOnClickListener = new View.OnClickListener() { + private final View.OnClickListener onClickListener = new View.OnClickListener() { public void onClick(View v) { String url = null; App app = appDetails.getApp(); @@ -1182,13 +1182,13 @@ public class AppDetails extends AppCompatActivity { url = app.donateURL; break; case R.id.bitcoin: - url = "bitcoin:" + app.bitcoinAddr; + url = app.getBitcoinUri(); break; case R.id.litecoin: - url = "litecoin:" + app.litecoinAddr; + url = app.getLitecoinUri(); break; case R.id.flattr: - url = "https://flattr.com/thing/" + app.flattrID; + url = app.getFlattrUri(); break; } if (url != null) { @@ -1268,7 +1268,7 @@ public class AppDetails extends AppCompatActivity { // Website button View tv = view.findViewById(R.id.website); if (!TextUtils.isEmpty(app.webURL)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1276,7 +1276,7 @@ public class AppDetails extends AppCompatActivity { // Email button tv = view.findViewById(R.id.email); if (!TextUtils.isEmpty(app.email)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1284,7 +1284,7 @@ public class AppDetails extends AppCompatActivity { // Source button tv = view.findViewById(R.id.source); if (!TextUtils.isEmpty(app.sourceURL)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1292,7 +1292,7 @@ public class AppDetails extends AppCompatActivity { // Issues button tv = view.findViewById(R.id.issues); if (!TextUtils.isEmpty(app.trackerURL)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1300,7 +1300,7 @@ public class AppDetails extends AppCompatActivity { // Changelog button tv = view.findViewById(R.id.changelog); if (!TextUtils.isEmpty(app.changelogURL)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1308,7 +1308,7 @@ public class AppDetails extends AppCompatActivity { // Donate button tv = view.findViewById(R.id.donate); if (!TextUtils.isEmpty(app.donateURL)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1316,7 +1316,7 @@ public class AppDetails extends AppCompatActivity { // Bitcoin tv = view.findViewById(R.id.bitcoin); if (!TextUtils.isEmpty(app.bitcoinAddr)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1324,7 +1324,7 @@ public class AppDetails extends AppCompatActivity { // Litecoin tv = view.findViewById(R.id.litecoin); if (!TextUtils.isEmpty(app.litecoinAddr)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1332,7 +1332,7 @@ public class AppDetails extends AppCompatActivity { // Flattr tv = view.findViewById(R.id.flattr); if (!TextUtils.isEmpty(app.flattrID)) { - tv.setOnClickListener(mOnClickListener); + tv.setOnClickListener(onClickListener); } else { tv.setVisibility(View.GONE); } @@ -1620,7 +1620,7 @@ public class AppDetails extends AppCompatActivity { NfcHelper.disableAndroidBeam(appDetails); // Set Install button and hide second button btMain.setText(R.string.menu_install); - btMain.setOnClickListener(mOnClickListener); + btMain.setOnClickListener(onClickListener); btMain.setEnabled(true); } else if (app.isInstalled()) { // If App is installed @@ -1638,7 +1638,7 @@ public class AppDetails extends AppCompatActivity { btMain.setText(R.string.menu_uninstall); } } - btMain.setOnClickListener(mOnClickListener); + btMain.setOnClickListener(onClickListener); btMain.setEnabled(true); } TextView author = (TextView) view.findViewById(R.id.author); @@ -1656,7 +1656,7 @@ public class AppDetails extends AppCompatActivity { } - private final View.OnClickListener mOnClickListener = new View.OnClickListener() { + private final View.OnClickListener onClickListener = new View.OnClickListener() { public void onClick(View v) { App app = appDetails.getApp(); AppDetails activity = (AppDetails) getActivity(); diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java new file mode 100644 index 000000000..52e06311f --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java @@ -0,0 +1,575 @@ +package org.fdroid.fdroid; + +import android.app.Activity; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.support.design.widget.CoordinatorLayout; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.app.AppCompatDelegate; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ImageView; +import android.widget.Toast; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; + +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppPrefsProvider; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.installer.InstallManagerService; +import org.fdroid.fdroid.installer.Installer; +import org.fdroid.fdroid.installer.InstallerFactory; +import org.fdroid.fdroid.installer.InstallerService; +import org.fdroid.fdroid.net.Downloader; +import org.fdroid.fdroid.net.DownloaderService; +import org.fdroid.fdroid.views.AppDetailsRecyclerViewAdapter; +import org.fdroid.fdroid.views.ShareChooserDialog; + +public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog.ShareChooserDialogListener, AppDetailsRecyclerViewAdapter.AppDetailsRecyclerViewAdapterCallbacks { + static { + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + } + + private static final String TAG = "AppDetails2"; + + private static final int REQUEST_ENABLE_BLUETOOTH = 2; + private static final int REQUEST_PERMISSION_DIALOG = 3; + private static final int REQUEST_UNINSTALL_DIALOG = 4; + + private FDroidApp fdroidApp; + private App app; + private RecyclerView recyclerView; + private AppDetailsRecyclerViewAdapter adapter; + private LocalBroadcastManager localBroadcastManager; + private String activeDownloadUrlString; + + @Override + protected void onCreate(Bundle savedInstanceState) { + fdroidApp = (FDroidApp) getApplication(); + //fdroidApp.applyTheme(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.app_details2); + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setTitle(""); // Nice and clean toolbar + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + if (!reset(getPackageNameFromIntent(getIntent()))) { + finish(); + return; + } + + localBroadcastManager = LocalBroadcastManager.getInstance(this); + + recyclerView = (RecyclerView) findViewById(R.id.rvDetails); + adapter = new AppDetailsRecyclerViewAdapter(this, app, this); + LinearLayoutManager lm = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false); + lm.setStackFromEnd(false); + recyclerView.setLayoutManager(lm); + recyclerView.setAdapter(adapter); + + // Load the feature graphic, if present + if (!TextUtils.isEmpty(app.iconUrlLarge)) { + ImageView ivFeatureGraphic = (ImageView) findViewById(R.id.feature_graphic); + DisplayImageOptions displayImageOptions = new DisplayImageOptions.Builder() + .cacheInMemory(false) + .cacheOnDisk(true) + .imageScaleType(ImageScaleType.NONE) + .bitmapConfig(Bitmap.Config.RGB_565) + .build(); + ImageLoader.getInstance().displayImage(app.iconUrlLarge, ivFeatureGraphic, displayImageOptions); + } + } + + private String getPackageNameFromIntent(Intent intent) { + if (!intent.hasExtra(AppDetails.EXTRA_APPID)) { + Log.e(TAG, "No package name found in the intent!"); + return null; + } + return intent.getStringExtra(AppDetails.EXTRA_APPID); + } + + /** + * 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, R.string.no_such_app, Toast.LENGTH_LONG).show(); + finish(); + return; + } + app = newApp; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + boolean ret = super.onCreateOptionsMenu(menu); + if (ret) { + getMenuInflater().inflate(R.menu.details2, menu); + } + return ret; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + if (app == null) { + return true; + } + MenuItem itemIgnoreAll = menu.findItem(R.id.action_ignore_all); + if (itemIgnoreAll != null) { + itemIgnoreAll.setChecked(app.getPrefs(this).ignoreAllUpdates); + } + MenuItem itemIgnoreThis = menu.findItem(R.id.action_ignore_this); + if (itemIgnoreThis != null) { + itemIgnoreThis.setVisible(app.hasUpdates()); + itemIgnoreThis.setChecked(app.getPrefs(this).ignoreThisUpdate >= app.suggestedVersionCode); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_share) { + 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.packageName); + + boolean showNearbyItem = app.isInstalled() && fdroidApp.bluetoothAdapter != null; + ShareChooserDialog.createChooser((CoordinatorLayout) findViewById(R.id.rootCoordinator), this, this, shareIntent, showNearbyItem); + return true; + } else if (item.getItemId() == R.id.action_ignore_all) { + app.getPrefs(this).ignoreAllUpdates ^= true; + item.setChecked(app.getPrefs(this).ignoreAllUpdates); + AppPrefsProvider.Helper.update(this, app, app.getPrefs(this)); + return true; + } else if (item.getItemId() == R.id.action_ignore_this) { + if (app.getPrefs(this).ignoreThisUpdate >= app.suggestedVersionCode) { + app.getPrefs(this).ignoreThisUpdate = 0; + } else { + app.getPrefs(this).ignoreThisUpdate = app.suggestedVersionCode; + } + item.setChecked(app.getPrefs(this).ignoreThisUpdate > 0); + AppPrefsProvider.Helper.update(this, app, app.getPrefs(this)); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onNearby() { + // 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 + } + + @Override + public void onResolvedShareIntent(Intent shareIntent) { + startActivity(shareIntent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_ENABLE_BLUETOOTH: + fdroidApp.sendViaBluetooth(this, resultCode, app.packageName); + break; + case REQUEST_PERMISSION_DIALOG: + if (resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + Apk apk = ApkProvider.Helper.findByUri(this, uri, Schema.ApkTable.Cols.ALL); + startInstall(apk); + } + break; + case REQUEST_UNINSTALL_DIALOG: + if (resultCode == Activity.RESULT_OK) { + startUninstall(); + } + break; + } + } + + // Install the version of this app denoted by 'app.curApk'. + @Override + public void installApk(final Apk apk) { + if (isFinishing()) { + return; + } + + if (!apk.compatible) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.installIncompatible); + builder.setPositiveButton(R.string.yes, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, + int whichButton) { + initiateInstall(apk); + } + }); + builder.setNegativeButton(R.string.no, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, + int whichButton) { + } + }); + AlertDialog alert = builder.create(); + alert.show(); + return; + } + if (app.installedSig != null && apk.sig != null + && !apk.sig.equals(app.installedSig)) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.SignatureMismatch).setPositiveButton( + R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + return; + } + initiateInstall(apk); + } + + private void initiateInstall(Apk apk) { + Installer installer = InstallerFactory.create(this, apk); + Intent intent = installer.getPermissionScreen(); + if (intent != null) { + // permission screen required + Utils.debugLog(TAG, "permission screen required"); + startActivityForResult(intent, REQUEST_PERMISSION_DIALOG); + return; + } + + startInstall(apk); + } + + private void startInstall(Apk apk) { + activeDownloadUrlString = apk.getUrl(); + registerDownloaderReceiver(); + InstallManagerService.queue(this, app, apk); + } + + private void startUninstall() { + registerUninstallReceiver(); + InstallerService.uninstall(this, app.installedApk); + } + + private void registerUninstallReceiver() { + localBroadcastManager.registerReceiver(uninstallReceiver, + Installer.getUninstallIntentFilter(app.packageName)); + } + + private void unregisterUninstallReceiver() { + localBroadcastManager.unregisterReceiver(uninstallReceiver); + } + + private void registerDownloaderReceiver() { + if (activeDownloadUrlString != null) { // if a download is active + String url = activeDownloadUrlString; + localBroadcastManager.registerReceiver(downloadReceiver, + DownloaderService.getIntentFilter(url)); + } + } + + private void unregisterDownloaderReceiver() { + localBroadcastManager.unregisterReceiver(downloadReceiver); + } + + private void unregisterInstallReceiver() { + localBroadcastManager.unregisterReceiver(installReceiver); + } + + private final BroadcastReceiver downloadReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case Downloader.ACTION_STARTED: + adapter.setProgress(-1, -1, R.string.download_pending); + break; + case Downloader.ACTION_PROGRESS: + adapter.setProgress(intent.getIntExtra(Downloader.EXTRA_BYTES_READ, -1), + intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, -1), 0); + break; + case Downloader.ACTION_COMPLETE: + // Starts the install process once the download is complete. + cleanUpFinishedDownload(); + localBroadcastManager.registerReceiver(installReceiver, + Installer.getInstallIntentFilter(intent.getData())); + 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(); + } + cleanUpFinishedDownload(); + break; + default: + throw new RuntimeException("intent action not handled!"); + } + } + }; + + private final BroadcastReceiver installReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case Installer.ACTION_INSTALL_STARTED: + adapter.setProgress(-1, -1, R.string.installing); + break; + case Installer.ACTION_INSTALL_COMPLETE: + adapter.clearProgress(); + unregisterInstallReceiver(); + onAppChanged(); + break; + case Installer.ACTION_INSTALL_INTERRUPTED: + adapter.clearProgress(); + onAppChanged(); + + String errorMessage = + intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); + + if (!TextUtils.isEmpty(errorMessage)) { + Log.e(TAG, "install aborted with errorMessage: " + errorMessage); + + String title = String.format( + getString(R.string.install_error_notify_title), + app.name); + + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(AppDetails2.this); + alertBuilder.setTitle(title); + alertBuilder.setMessage(errorMessage); + alertBuilder.setNeutralButton(android.R.string.ok, null); + alertBuilder.create().show(); + } + unregisterInstallReceiver(); + break; + case Installer.ACTION_INSTALL_USER_INTERACTION: + PendingIntent installPendingIntent = + intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); + + try { + installPendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.e(TAG, "PI canceled", e); + } + + break; + default: + throw new RuntimeException("intent action not handled!"); + } + } + }; + + private final BroadcastReceiver uninstallReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case Installer.ACTION_UNINSTALL_STARTED: + adapter.setProgress(-1, -1, R.string.uninstalling); + break; + case Installer.ACTION_UNINSTALL_COMPLETE: + adapter.clearProgress(); + onAppChanged(); + unregisterUninstallReceiver(); + break; + case Installer.ACTION_UNINSTALL_INTERRUPTED: + adapter.clearProgress(); + String errorMessage = + intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); + + if (!TextUtils.isEmpty(errorMessage)) { + Log.e(TAG, "uninstall aborted with errorMessage: " + errorMessage); + + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(AppDetails2.this); + alertBuilder.setTitle(R.string.uninstall_error_notify_title); + alertBuilder.setMessage(errorMessage); + alertBuilder.setNeutralButton(android.R.string.ok, null); + alertBuilder.create().show(); + } + unregisterUninstallReceiver(); + break; + case Installer.ACTION_UNINSTALL_USER_INTERACTION: + PendingIntent uninstallPendingIntent = + intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); + + try { + uninstallPendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.e(TAG, "PI canceled", e); + } + + break; + default: + throw new RuntimeException("intent action not handled!"); + } + } + }; + + /** + * 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 packageName) { + + Utils.debugLog(TAG, "Getting application details for " + packageName); + App newApp = null; + + calcActiveDownloadUrlString(packageName); + + if (!TextUtils.isEmpty(packageName)) { + newApp = AppProvider.Helper.findHighestPriorityMetadata(getContentResolver(), packageName); + } + + setApp(newApp); + return this.app != null; + } + + private void calcActiveDownloadUrlString(String packageName) { + String urlString = getPreferences(MODE_PRIVATE).getString(packageName, null); + if (DownloaderService.isQueuedOrActive(urlString)) { + activeDownloadUrlString = urlString; + } else { + // this URL is no longer active, remove it + getPreferences(MODE_PRIVATE).edit().remove(packageName).apply(); + } + } + + /** + * Remove progress listener, suppress progress bar, set downloadHandler to null. + */ + private void cleanUpFinishedDownload() { + activeDownloadUrlString = null; + adapter.clearProgress(); + unregisterDownloaderReceiver(); + } + + private void onAppChanged() { + recyclerView.post(new Runnable() { + @Override + public void run() { + if (!reset(app.packageName)) { + AppDetails2.this.finish(); + return; + } + AppDetailsRecyclerViewAdapter adapter = (AppDetailsRecyclerViewAdapter) recyclerView.getAdapter(); + adapter.updateItems(app); + supportInvalidateOptionsMenu(); + } + }); + } + + @Override + public boolean isAppDownloading() { + return !TextUtils.isEmpty(activeDownloadUrlString); + } + + @Override + public void enableAndroidBeam() { + NfcHelper.setAndroidBeam(this, app.packageName); + } + + @Override + public void disableAndroidBeam() { + NfcHelper.disableAndroidBeam(this); + } + + @Override + public void openUrl(String url) { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + 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 void installCancel() { + if (!TextUtils.isEmpty(activeDownloadUrlString)) { + InstallManagerService.cancel(this, activeDownloadUrlString); + } + } + + @Override + public void launchApk() { + Intent intent = getPackageManager().getLaunchIntentForPackage(app.packageName); + startActivity(intent); + } + + @Override + public void installApk() { + Apk apkToInstall = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); + installApk(apkToInstall); + } + + @Override + public void upgradeApk() { + Apk apkToInstall = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); + installApk(apkToInstall); + } + + @Override + public void uninstallApk() { + Apk apk = app.installedApk; + if (apk == null) { + // TODO ideally, app would be refreshed immediately after install, then this + // workaround would be unnecessary + try { + PackageInfo pi = getPackageManager().getPackageInfo(app.packageName, 0); + apk = ApkProvider.Helper.findApkFromAnyRepo(this, pi.packageName, pi.versionCode); + app.installedApk = apk; + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return; // not installed + } + } + Installer installer = InstallerFactory.create(this, apk); + Intent intent = installer.getUninstallScreen(); + if (intent != null) { + // uninstall screen required + Utils.debugLog(TAG, "screen screen required"); + startActivityForResult(intent, REQUEST_UNINSTALL_DIALOG); + return; + } + startUninstall(); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java index eca09c6bd..08a39be78 100644 --- a/app/src/main/java/org/fdroid/fdroid/Utils.java +++ b/app/src/main/java/org/fdroid/fdroid/Utils.java @@ -20,6 +20,7 @@ package org.fdroid.fdroid; import android.content.Context; import android.content.pm.PackageManager; +import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; @@ -31,6 +32,7 @@ import android.text.Html; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.Log; +import android.util.TypedValue; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.assist.ImageScaleType; @@ -596,4 +598,8 @@ public final class Utils { return data; } + public static int dpToPx(int dp, Context ctx) { + Resources r = ctx.getResources(); + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, r.getDisplayMetrics()); + } } diff --git a/app/src/main/java/org/fdroid/fdroid/compat/PRNGFixes.java b/app/src/main/java/org/fdroid/fdroid/compat/PRNGFixes.java index 667d1fd70..7dc71f525 100644 --- a/app/src/main/java/org/fdroid/fdroid/compat/PRNGFixes.java +++ b/app/src/main/java/org/fdroid/fdroid/compat/PRNGFixes.java @@ -202,7 +202,7 @@ public final class PRNGFixes { * each instance needs to seed itself if the client does not explicitly * seed it. */ - private boolean mSeeded; + private boolean seeded; @Override protected void engineSetSeed(byte[] bytes) { @@ -218,13 +218,13 @@ public final class PRNGFixes { // Log and ignore. Log.w(TAG, "Failed to mix seed into " + URANDOM_FILE); } finally { - mSeeded = true; + seeded = true; } } @Override protected void engineNextBytes(byte[] bytes) { - if (!mSeeded) { + if (!seeded) { // Mix in the device- and invocation-specific seed. engineSetSeed(generateSeed()); } diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index 671fca3cd..60e5cfd44 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -12,6 +12,7 @@ import android.database.Cursor; import android.os.Environment; import android.os.Parcel; import android.os.Parcelable; +import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; @@ -552,6 +553,21 @@ public class App extends ValueObject implements Comparable, Parcelable { return new AppFilter().filter(this); } + @Nullable + public String getBitcoinUri() { + return TextUtils.isEmpty(bitcoinAddr) ? null : "bitcoin:" + bitcoinAddr; + } + + @Nullable + public String getLitecoinUri() { + return TextUtils.isEmpty(litecoinAddr) ? null : "litecoin:" + litecoinAddr; + } + + @Nullable + public String getFlattrUri() { + return TextUtils.isEmpty(flattrID) ? null : "https://flattr.com/thing/" + flattrID; + } + public String getSuggestedVersionName() { return suggestedVersionName; } diff --git a/app/src/main/java/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java b/app/src/main/java/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java index fbee8f591..587cb91bb 100644 --- a/app/src/main/java/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java +++ b/app/src/main/java/org/fdroid/fdroid/localrepo/peers/BonjourFinder.java @@ -46,7 +46,7 @@ final class BonjourFinder extends PeerFinder implements ServiceListener { private JmDNS jmdns; private WifiManager wifiManager; - private WifiManager.MulticastLock mMulticastLock; + private WifiManager.MulticastLock multicastLock; private BonjourFinder(Context context, Subscriber subscriber) { super(context, subscriber); @@ -58,8 +58,8 @@ final class BonjourFinder extends PeerFinder implements ServiceListener { if (wifiManager == null) { wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); - mMulticastLock = wifiManager.createMulticastLock(context.getPackageName()); - mMulticastLock.setReferenceCounted(false); + multicastLock = wifiManager.createMulticastLock(context.getPackageName()); + multicastLock.setReferenceCounted(false); } if (isScanning) { @@ -68,7 +68,7 @@ final class BonjourFinder extends PeerFinder implements ServiceListener { } isScanning = true; - mMulticastLock.acquire(); + multicastLock.acquire(); try { Utils.debugLog(TAG, "Searching for Bonjour (mDNS) clients..."); @@ -146,8 +146,8 @@ final class BonjourFinder extends PeerFinder implements ServiceListener { private void cancel() { Utils.debugLog(TAG, "Cancelling BonjourFinder, releasing multicast lock, removing jmdns service listeners"); - if (mMulticastLock != null) { - mMulticastLock.release(); + if (multicastLock != null) { + multicastLock.release(); } isScanning = false; diff --git a/app/src/main/java/org/fdroid/fdroid/privileged/install/InstallExtensionDialogActivity.java b/app/src/main/java/org/fdroid/fdroid/privileged/install/InstallExtensionDialogActivity.java index 1354a789c..c6be97c07 100644 --- a/app/src/main/java/org/fdroid/fdroid/privileged/install/InstallExtensionDialogActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/privileged/install/InstallExtensionDialogActivity.java @@ -129,7 +129,7 @@ public class InstallExtensionDialogActivity extends FragmentActivity { * 1. Check for root access */ private final AsyncTask checkRootTask = new AsyncTask() { - ProgressDialog mProgressDialog; + ProgressDialog progressDialog; @Override protected void onPreExecute() { @@ -139,11 +139,11 @@ public class InstallExtensionDialogActivity extends FragmentActivity { ContextThemeWrapper theme = new ContextThemeWrapper(InstallExtensionDialogActivity.this, FDroidApp.getCurThemeResId()); - mProgressDialog = new ProgressDialog(theme); - mProgressDialog.setMessage(getString(R.string.requesting_root_access_body)); - mProgressDialog.setIndeterminate(true); - mProgressDialog.setCancelable(false); - mProgressDialog.show(); + progressDialog = new ProgressDialog(theme); + progressDialog.setMessage(getString(R.string.requesting_root_access_body)); + progressDialog.setIndeterminate(true); + progressDialog.setCancelable(false); + progressDialog.show(); } @Override @@ -155,7 +155,7 @@ public class InstallExtensionDialogActivity extends FragmentActivity { protected void onPostExecute(Boolean rootGranted) { super.onPostExecute(rootGranted); - mProgressDialog.dismiss(); + progressDialog.dismiss(); if (rootGranted) { // root access granted @@ -193,7 +193,7 @@ public class InstallExtensionDialogActivity extends FragmentActivity { * 2. Install into system */ private final AsyncTask installTask = new AsyncTask() { - ProgressDialog mProgressDialog; + ProgressDialog progressDialog; @Override protected void onPreExecute() { @@ -203,11 +203,11 @@ public class InstallExtensionDialogActivity extends FragmentActivity { ContextThemeWrapper theme = new ContextThemeWrapper(InstallExtensionDialogActivity.this, FDroidApp.getCurThemeResId()); - mProgressDialog = new ProgressDialog(theme); - mProgressDialog.setMessage(InstallExtension.create(getApplicationContext()).getInstallingString()); - mProgressDialog.setIndeterminate(true); - mProgressDialog.setCancelable(false); - mProgressDialog.show(); + progressDialog = new ProgressDialog(theme); + progressDialog.setMessage(InstallExtension.create(getApplicationContext()).getInstallingString()); + progressDialog.setIndeterminate(true); + progressDialog.setCancelable(false); + progressDialog.show(); } @Override @@ -298,7 +298,7 @@ public class InstallExtensionDialogActivity extends FragmentActivity { } private final AsyncTask uninstallTask = new AsyncTask() { - ProgressDialog mProgressDialog; + ProgressDialog progressDialog; @Override protected void onPreExecute() { @@ -308,11 +308,11 @@ public class InstallExtensionDialogActivity extends FragmentActivity { ContextThemeWrapper theme = new ContextThemeWrapper(InstallExtensionDialogActivity.this, FDroidApp.getCurThemeResId()); - mProgressDialog = new ProgressDialog(theme); - mProgressDialog.setMessage(getString(R.string.system_install_uninstalling)); - mProgressDialog.setIndeterminate(true); - mProgressDialog.setCancelable(false); - mProgressDialog.show(); + progressDialog = new ProgressDialog(theme); + progressDialog.setMessage(getString(R.string.system_install_uninstalling)); + progressDialog.setIndeterminate(true); + progressDialog.setCancelable(false); + progressDialog.show(); } @Override @@ -325,7 +325,7 @@ public class InstallExtensionDialogActivity extends FragmentActivity { protected void onPostExecute(Void unused) { super.onPostExecute(unused); - mProgressDialog.dismiss(); + progressDialog.dismiss(); // app is uninstalled but still display, kill it! System.exit(0); diff --git a/app/src/main/java/org/fdroid/fdroid/privileged/views/AppSecurityPermissions.java b/app/src/main/java/org/fdroid/fdroid/privileged/views/AppSecurityPermissions.java index e4b2b1c4a..74176e9f4 100644 --- a/app/src/main/java/org/fdroid/fdroid/privileged/views/AppSecurityPermissions.java +++ b/app/src/main/java/org/fdroid/fdroid/privileged/views/AppSecurityPermissions.java @@ -90,7 +90,7 @@ public class AppSecurityPermissions { // PermissionGroupInfo implements Parcelable but its Parcel constructor is private and thus cannot be extended. @SuppressLint("ParcelCreator") static class MyPermissionGroupInfo extends PermissionGroupInfo { - CharSequence mLabel; + CharSequence label; final List newPermissions = new ArrayList<>(); final List allPermissions = new ArrayList<>(); @@ -185,7 +185,7 @@ public class AppSecurityPermissions { } PackageManager pm = getContext().getPackageManager(); AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setTitle(group.mLabel); + builder.setTitle(group.label); if (perm.descriptionRes != 0) { builder.setMessage(perm.loadDescription(pm)); } else { @@ -431,22 +431,22 @@ public class AppSecurityPermissions { private static class PermissionGroupInfoComparator implements Comparator { - private final Collator sCollator = Collator.getInstance(); + private final Collator collator = Collator.getInstance(); public final int compare(MyPermissionGroupInfo a, MyPermissionGroupInfo b) { - return sCollator.compare(a.mLabel, b.mLabel); + return collator.compare(a.label, b.label); } } private static class PermissionInfoComparator implements Comparator { - private final Collator sCollator = Collator.getInstance(); + private final Collator collator = Collator.getInstance(); PermissionInfoComparator() { } public final int compare(MyPermissionInfo a, MyPermissionInfo b) { - return sCollator.compare(a.label, b.label); + return collator.compare(a.label, b.label); } } @@ -482,13 +482,13 @@ public class AppSecurityPermissions { for (MyPermissionGroupInfo pgrp : permGroups.values()) { if (pgrp.labelRes != 0 || pgrp.nonLocalizedLabel != null) { - pgrp.mLabel = pgrp.loadLabel(pm); + pgrp.label = pgrp.loadLabel(pm); } else { try { ApplicationInfo app = pm.getApplicationInfo(pgrp.packageName, 0); - pgrp.mLabel = app.loadLabel(pm); + pgrp.label = app.loadLabel(pm); } catch (NameNotFoundException e) { - pgrp.mLabel = pgrp.loadLabel(pm); + pgrp.label = pgrp.loadLabel(pm); } } permGroupsList.add(pgrp); diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java new file mode 100644 index 000000000..a14b8602d --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -0,0 +1,881 @@ +package org.fdroid.fdroid.views; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.v4.view.ViewCompat; +import android.support.v4.widget.TextViewCompat; +import android.support.v7.text.AllCapsTransformationMethod; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.Html; +import android.text.Spannable; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.text.method.LinkMovementMethod; +import android.text.style.URLSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +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 com.nostra13.universalimageloader.core.assist.ImageScaleType; + +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.data.InstalledAppProvider; +import org.fdroid.fdroid.data.RepoProvider; +import org.fdroid.fdroid.privileged.views.AppDiff; +import org.fdroid.fdroid.privileged.views.AppSecurityPermissions; + +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.List; + +public class AppDetailsRecyclerViewAdapter + extends RecyclerView.Adapter { + + public interface AppDetailsRecyclerViewAdapterCallbacks { + + boolean isAppDownloading(); + + void enableAndroidBeam(); + + void disableAndroidBeam(); + + void openUrl(String url); + + void installApk(); + + void installApk(Apk apk); + + void upgradeApk(); + + void uninstallApk(); + + void installCancel(); + + void launchApk(); + + } + + private static final int VIEWTYPE_HEADER = 0; + private static final int VIEWTYPE_SCREENSHOTS = 1; + private static final int VIEWTYPE_WHATS_NEW = 2; + private static final int VIEWTYPE_DONATE = 3; + private static final int VIEWTYPE_LINKS = 4; + private static final int VIEWTYPE_PERMISSIONS = 5; + private static final int VIEWTYPE_VERSIONS = 6; + private static final int VIEWTYPE_VERSION = 7; + + private final Context context; + @NonNull + private App app; + private final AppDetailsRecyclerViewAdapterCallbacks callbacks; + private RecyclerView recyclerView; + private ArrayList items; + private ArrayList versions; + private boolean showVersions; + + private HeaderViewHolder headerView; + + public AppDetailsRecyclerViewAdapter(Context context, @NonNull App app, AppDetailsRecyclerViewAdapterCallbacks callbacks) { + this.context = context; + this.callbacks = callbacks; + this.app = app; + updateItems(app); + } + + public void updateItems(@NonNull App app) { + this.app = app; + + // Get versions + versions = new ArrayList<>(); + final List apks = ApkProvider.Helper.findByPackageName(context, this.app.packageName); + for (final Apk apk : apks) { + if (apk.compatible || Preferences.get().showIncompatibleVersions()) { + versions.add(apk); + } + } + + if (items == null) { + items = new ArrayList<>(); + } else { + items.clear(); + } + addItem(VIEWTYPE_HEADER); + addItem(VIEWTYPE_SCREENSHOTS); + addItem(VIEWTYPE_WHATS_NEW); + addItem(VIEWTYPE_DONATE); + addItem(VIEWTYPE_LINKS); + addItem(VIEWTYPE_PERMISSIONS); + addItem(VIEWTYPE_VERSIONS); + + notifyDataSetChanged(); + } + + private void setShowVersions(boolean showVersions) { + this.showVersions = showVersions; + items.removeAll(versions); + if (showVersions) { + items.addAll(items.indexOf(VIEWTYPE_VERSIONS) + 1, versions); + } + notifyDataSetChanged(); + } + + private void addItem(int item) { + // Gives us a chance to hide sections that are not used, e.g. the donate section when + // we have no donation links. + if (item == VIEWTYPE_DONATE && !shouldShowDonate()) { + return; + } else if (item == VIEWTYPE_PERMISSIONS && !shouldShowPermissions()) { + return; + } + items.add(item); + } + + private boolean shouldShowPermissions() { + // Figure out if we should show permissions section + Apk curApk = null; + for (int i = 0; i < versions.size(); i++) { + final Apk apk = versions.get(i); + if (apk.versionCode == app.suggestedVersionCode) { + curApk = apk; + break; + } + } + final boolean curApkCompatible = curApk != null && curApk.compatible; + return versions.size() > 0 && (curApkCompatible || Preferences.get().showIncompatibleVersions()); + } + + private boolean shouldShowDonate() { + return uriIsSetAndCanBeOpened(app.donateURL) || + uriIsSetAndCanBeOpened(app.getBitcoinUri()) || + uriIsSetAndCanBeOpened(app.getLitecoinUri()) || + uriIsSetAndCanBeOpened(app.getFlattrUri()); + } + + public void clearProgress() { + setProgress(0, 0, 0); + } + + public void setProgress(int bytesDownloaded, int totalBytes, int resIdString) { + if (headerView != null) { + headerView.setProgress(bytesDownloaded, totalBytes, resIdString); + } + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEWTYPE_HEADER: + View header = inflater.inflate(R.layout.app_details2_header, parent, false); + return new HeaderViewHolder(header); + case VIEWTYPE_SCREENSHOTS: + View screenshots = inflater.inflate(R.layout.app_details2_screenshots, parent, false); + return new ScreenShotsViewHolder(screenshots); + case VIEWTYPE_WHATS_NEW: + View whatsNew = inflater.inflate(R.layout.app_details2_whatsnew, parent, false); + return new WhatsNewViewHolder(whatsNew); + case VIEWTYPE_DONATE: + View donate = inflater.inflate(R.layout.app_details2_donate, parent, false); + return new DonateViewHolder(donate); + case VIEWTYPE_LINKS: + View links = inflater.inflate(R.layout.app_details2_links, parent, false); + return new LinksViewHolder(links); + case VIEWTYPE_PERMISSIONS: + View permissions = inflater.inflate(R.layout.app_details2_links, parent, false); + return new PermissionsViewHolder(permissions); + case VIEWTYPE_VERSIONS: + View versions = inflater.inflate(R.layout.app_details2_links, parent, false); + return new VersionsViewHolder(versions); + case VIEWTYPE_VERSION: + View version = inflater.inflate(R.layout.apklistitem, parent, false); + return new VersionViewHolder(version); + } + return null; + } + + @Override + public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) { + int viewType = getItemViewType(position); + switch (viewType) { + case VIEWTYPE_HEADER: + HeaderViewHolder header = (HeaderViewHolder) holder; + headerView = header; + header.bindModel(); + break; + case VIEWTYPE_SCREENSHOTS: + ((ScreenShotsViewHolder) holder).bindModel(); + break; + case VIEWTYPE_WHATS_NEW: + ((WhatsNewViewHolder) holder).bindModel(); + break; + case VIEWTYPE_DONATE: + ((DonateViewHolder) holder).bindModel(); + break; + case VIEWTYPE_LINKS: + ((LinksViewHolder) holder).bindModel(); + break; + case VIEWTYPE_PERMISSIONS: + ((PermissionsViewHolder) holder).bindModel(); + break; + case VIEWTYPE_VERSIONS: + ((VersionsViewHolder) holder).bindModel(); + break; + case VIEWTYPE_VERSION: + final Apk apk = (Apk) items.get(position); + ((VersionViewHolder) holder).bindModel(apk); + break; + } + } + + @Override + public void onViewRecycled(RecyclerView.ViewHolder holder) { + if (holder instanceof HeaderViewHolder) { + headerView = null; + } + super.onViewRecycled(holder); + } + + @Override + public int getItemCount() { + return items.size(); + } + + @Override + public int getItemViewType(int position) { + if (items.get(position) instanceof Apk) { + return VIEWTYPE_VERSION; + } + return (Integer) items.get(position); + } + + public class HeaderViewHolder extends RecyclerView.ViewHolder { + private static final int MAX_LINES = 5; + + final ImageView iconView; + final TextView titleView; + final TextView authorView; + final TextView summaryView; + final TextView descriptionView; + final TextView descriptionMoreView; + final View buttonLayout; + final Button buttonPrimaryView; + final Button buttonSecondaryView; + final View progressLayout; + final ProgressBar progressBar; + final TextView progressLabel; + final TextView progressPercent; + final View progressCancel; + final DisplayImageOptions displayImageOptions; + + HeaderViewHolder(View view) { + super(view); + iconView = (ImageView) view.findViewById(R.id.icon); + titleView = (TextView) view.findViewById(R.id.title); + authorView = (TextView) view.findViewById(R.id.author); + summaryView = (TextView) view.findViewById(R.id.summary); + descriptionView = (TextView) view.findViewById(R.id.description); + descriptionMoreView = (TextView) view.findViewById(R.id.description_more); + buttonLayout = view.findViewById(R.id.button_layout); + buttonPrimaryView = (Button) view.findViewById(R.id.primaryButtonView); + buttonSecondaryView = (Button) view.findViewById(R.id.secondaryButtonView); + progressLayout = view.findViewById(R.id.progress_layout); + progressBar = (ProgressBar) view.findViewById(R.id.progress_bar); + progressLabel = (TextView) view.findViewById(R.id.progress_label); + progressPercent = (TextView) view.findViewById(R.id.progress_percent); + progressCancel = view.findViewById(R.id.progress_cancel); + 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(); + descriptionView.setMaxLines(MAX_LINES); + descriptionView.setEllipsize(TextUtils.TruncateAt.MARQUEE); + descriptionMoreView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // Make this "header section" the focused child, so that RecyclerView will use + // it as the anchor in the layout process. Otherwise the RV might select another + // view as the anchor, resulting in that the top of this view is instead scrolled + // off the screen. Refer to LinearLayoutManager.updateAnchorFromChildren(...). + recyclerView.requestChildFocus(itemView, itemView); + if (TextViewCompat.getMaxLines(descriptionView) != MAX_LINES) { + descriptionView.setMaxLines(MAX_LINES); + descriptionMoreView.setText(R.string.more); + } else { + descriptionView.setMaxLines(Integer.MAX_VALUE); + descriptionMoreView.setText(R.string.less); + } + } + }); + // Set ALL caps (in a way compatible with SDK 10) + AllCapsTransformationMethod allCapsTransformation = new AllCapsTransformationMethod(view.getContext()); + buttonPrimaryView.setTransformationMethod(allCapsTransformation); + buttonSecondaryView.setTransformationMethod(allCapsTransformation); + descriptionMoreView.setTransformationMethod(allCapsTransformation); + } + + public void setProgress(int bytesDownloaded, int totalBytes, int resIdString) { + if (bytesDownloaded == 0 && totalBytes == 0) { + // Remove progress bar + progressLayout.setVisibility(View.GONE); + buttonLayout.setVisibility(View.VISIBLE); + } else { + progressBar.setMax(totalBytes); + progressBar.setProgress(bytesDownloaded); + progressBar.setIndeterminate(totalBytes == -1); + if (resIdString != 0) { + progressLabel.setText(resIdString); + progressPercent.setText(""); + } else if (totalBytes > 0 && bytesDownloaded >= 0) { + float percent = bytesDownloaded * 100 / totalBytes; + progressLabel.setText(Utils.getFriendlySize(bytesDownloaded) + " / " + Utils.getFriendlySize(totalBytes)); + NumberFormat format = NumberFormat.getPercentInstance(); + format.setMaximumFractionDigits(0); + progressPercent.setText(format.format(percent / 100)); + } else if (bytesDownloaded >= 0) { + progressLabel.setText(Utils.getFriendlySize(bytesDownloaded)); + progressPercent.setText(""); + } + + // Make sure it's visible + if (progressLayout.getVisibility() != View.VISIBLE) { + progressLayout.setVisibility(View.VISIBLE); + buttonLayout.setVisibility(View.GONE); + } + } + } + + public void bindModel() { + ImageLoader.getInstance().displayImage(app.iconUrlLarge, iconView, displayImageOptions); + titleView.setText(app.name); + if (!TextUtils.isEmpty(app.author)) { + authorView.setText(context.getString(R.string.by_author) + " " + app.author); + authorView.setVisibility(View.VISIBLE); + } else { + authorView.setVisibility(View.GONE); + } + summaryView.setText(app.summary); + final Spanned desc = Html.fromHtml(app.description, null, new Utils.HtmlTagHandler()); + descriptionView.setMovementMethod(LinkMovementMethod.getInstance()); + descriptionView.setText(trimTrailingNewlines(desc)); + if (descriptionView.getText() instanceof Spannable) { + Spannable spannable = (Spannable) descriptionView.getText(); + URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); + for (URLSpan span : spans) { + int start = spannable.getSpanStart(span); + int end = spannable.getSpanEnd(span); + int flags = spannable.getSpanFlags(span); + spannable.removeSpan(span); + // Create out own safe link span + SafeURLSpan safeUrlSpan = new SafeURLSpan(span.getURL()); + spannable.setSpan(safeUrlSpan, start, end, flags); + } + } + descriptionView.post(new Runnable() { + @Override + public void run() { + if (descriptionView.getLineCount() <= HeaderViewHolder.MAX_LINES) { + descriptionMoreView.setVisibility(View.GONE); + } else { + descriptionMoreView.setVisibility(View.VISIBLE); + } + } + }); + buttonSecondaryView.setText(R.string.menu_uninstall); + buttonSecondaryView.setVisibility(app.isInstalled() ? View.VISIBLE : View.INVISIBLE); + buttonSecondaryView.setOnClickListener(onUnInstallClickListener); + buttonPrimaryView.setText(R.string.menu_install); + buttonPrimaryView.setVisibility(versions.size() > 0 ? View.VISIBLE : View.GONE); + if (callbacks.isAppDownloading()) { + buttonPrimaryView.setText(R.string.downloading); + buttonPrimaryView.setEnabled(false); + } else if (!app.isInstalled() && app.suggestedVersionCode > 0 && versions.size() > 0) { + // Check count > 0 due to incompatible apps resulting in an empty list. + callbacks.disableAndroidBeam(); + // Set Install button and hide second button + buttonPrimaryView.setText(R.string.menu_install); + buttonPrimaryView.setOnClickListener(onInstallClickListener); + buttonPrimaryView.setEnabled(true); + } else if (app.isInstalled()) { + callbacks.enableAndroidBeam(); + if (app.canAndWantToUpdate(context)) { + buttonPrimaryView.setText(R.string.menu_upgrade); + buttonPrimaryView.setOnClickListener(onUpgradeClickListener); + } else { + if (context.getPackageManager().getLaunchIntentForPackage(app.packageName) != null) { + buttonPrimaryView.setText(R.string.menu_launch); + buttonPrimaryView.setOnClickListener(onLaunchClickListener); + } else { + buttonPrimaryView.setVisibility(View.GONE); + } + } + buttonPrimaryView.setEnabled(true); + } + if (callbacks.isAppDownloading()) { + buttonLayout.setVisibility(View.GONE); + progressLayout.setVisibility(View.VISIBLE); + } else { + buttonLayout.setVisibility(View.VISIBLE); + progressLayout.setVisibility(View.GONE); + } + progressCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.installCancel(); + } + }); + + } + } + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + this.recyclerView = recyclerView; + } + + @Override + public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + this.recyclerView = null; + super.onDetachedFromRecyclerView(recyclerView); + } + + private class ScreenShotsViewHolder extends RecyclerView.ViewHolder { + final RecyclerView recyclerView; + LinearLayoutManagerSnapHelper snapHelper; + + ScreenShotsViewHolder(View view) { + super(view); + recyclerView = (RecyclerView) view.findViewById(R.id.screenshots); + } + + public void bindModel() { + LinearLayoutManager lm = new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false); + recyclerView.setLayoutManager(lm); + ScreenShotsRecyclerViewAdapter adapter = new ScreenShotsRecyclerViewAdapter(itemView.getContext(), app); + recyclerView.setAdapter(adapter); + recyclerView.setHasFixedSize(true); + recyclerView.setNestedScrollingEnabled(false); + if (snapHelper != null) { + snapHelper.attachToRecyclerView(null); + } + snapHelper = new LinearLayoutManagerSnapHelper(lm); + snapHelper.setLinearSnapHelperListener(adapter); + snapHelper.attachToRecyclerView(recyclerView); + } + } + + private class WhatsNewViewHolder extends RecyclerView.ViewHolder { + final TextView textView; + + WhatsNewViewHolder(View view) { + super(view); + textView = (TextView) view.findViewById(R.id.text); + } + + public void bindModel() { + textView.setText("WHATS NEW GOES HERE"); + } + } + + private class DonateViewHolder extends RecyclerView.ViewHolder { + final TextView textView; + final LinearLayout contentView; + + DonateViewHolder(View view) { + super(view); + textView = (TextView) view.findViewById(R.id.information); + contentView = (LinearLayout) view.findViewById(R.id.ll_information); + } + + public void bindModel() { + contentView.removeAllViews(); + + // Donate button + if (uriIsSetAndCanBeOpened(app.donateURL)) { + addLinkItemView(contentView, R.string.menu_donate, R.drawable.ic_donate, app.donateURL); + } + + // Bitcoin + if (uriIsSetAndCanBeOpened(app.getBitcoinUri())) { + addLinkItemView(contentView, R.string.menu_bitcoin, R.drawable.ic_bitcoin, app.getBitcoinUri()); + } + + // Litecoin + if (uriIsSetAndCanBeOpened(app.getLitecoinUri())) { + addLinkItemView(contentView, R.string.menu_litecoin, R.drawable.ic_litecoin, app.getLitecoinUri()); + } + + // Flattr + if (uriIsSetAndCanBeOpened(app.getFlattrUri())) { + addLinkItemView(contentView, R.string.menu_flattr, R.drawable.ic_flattr, app.getFlattrUri()); + } + } + } + + private abstract class ExpandableLinearLayoutViewHolder extends RecyclerView.ViewHolder { + final TextView headerView; + final LinearLayout contentView; + + ExpandableLinearLayoutViewHolder(View view) { + super(view); + headerView = (TextView) view.findViewById(R.id.information); + contentView = (LinearLayout) view.findViewById(R.id.ll_content); + } + } + + private class VersionsViewHolder extends ExpandableLinearLayoutViewHolder { + + VersionsViewHolder(View view) { + super(view); + } + + public void bindModel() { + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + setShowVersions(!showVersions); + } + }); + headerView.setText(R.string.versions); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(headerView, R.drawable.ic_access_time_24dp_grey600, 0, showVersions ? R.drawable.ic_expand_less_grey600 : R.drawable.ic_expand_more_grey600, 0); + } + } + + private class PermissionsViewHolder extends ExpandableLinearLayoutViewHolder { + + PermissionsViewHolder(View view) { + super(view); + } + + public void bindModel() { + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean shouldBeVisible = contentView.getVisibility() != View.VISIBLE; + contentView.setVisibility(shouldBeVisible ? View.VISIBLE : View.GONE); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(headerView, R.drawable.ic_lock_24dp_grey600, 0, shouldBeVisible ? R.drawable.ic_expand_less_grey600 : R.drawable.ic_expand_more_grey600, 0); + } + }); + headerView.setText(R.string.permissions); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(headerView, R.drawable.ic_lock_24dp_grey600, 0, R.drawable.ic_expand_more_grey600, 0); + contentView.removeAllViews(); + AppDiff appDiff = new AppDiff(context.getPackageManager(), versions.get(0)); + AppSecurityPermissions perms = new AppSecurityPermissions(context, appDiff.pkgInfo); + contentView.addView(perms.getPermissionsView(AppSecurityPermissions.WHICH_ALL)); + } + } + + private class LinksViewHolder extends ExpandableLinearLayoutViewHolder { + + LinksViewHolder(View view) { + super(view); + } + + public void bindModel() { + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean shouldBeVisible = contentView.getVisibility() != View.VISIBLE; + contentView.setVisibility(shouldBeVisible ? View.VISIBLE : View.GONE); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(headerView, R.drawable.ic_website, 0, shouldBeVisible ? R.drawable.ic_expand_less_grey600 : R.drawable.ic_expand_more_grey600, 0); + } + }); + headerView.setText(R.string.links); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(headerView, R.drawable.ic_website, 0, R.drawable.ic_expand_more_grey600, 0); + contentView.removeAllViews(); + + // Source button + if (uriIsSetAndCanBeOpened(app.sourceURL)) { + addLinkItemView(contentView, R.string.menu_source, R.drawable.ic_source_code, app.sourceURL); + } + + // Issues button + if (uriIsSetAndCanBeOpened(app.trackerURL)) { + addLinkItemView(contentView, R.string.menu_issues, R.drawable.ic_issues, app.trackerURL); + } + + // Changelog button + if (uriIsSetAndCanBeOpened(app.changelogURL)) { + addLinkItemView(contentView, R.string.menu_changelog, R.drawable.ic_changelog, app.changelogURL); + } + + // Website button + if (uriIsSetAndCanBeOpened(app.webURL)) { + addLinkItemView(contentView, R.string.menu_website, R.drawable.ic_website, app.webURL); + } + + // Email button + final String subject = Uri.encode(context.getString(R.string.app_details_subject, app.name)); + String emailUrl = app.email == null ? null : ("mailto:" + app.email + "?subject=" + subject); + if (uriIsSetAndCanBeOpened(emailUrl)) { + addLinkItemView(contentView, R.string.menu_email, R.drawable.ic_email, emailUrl); + } + } + } + + private class VersionViewHolder extends RecyclerView.ViewHolder { + final TextView version; + final TextView status; + final TextView repository; + final TextView size; + final TextView api; + final TextView incompatibleReasons; + final TextView buildtype; + final TextView added; + final TextView nativecode; + + VersionViewHolder(View view) { + super(view); + version = (TextView) view.findViewById(R.id.version); + status = (TextView) view.findViewById(R.id.status); + repository = (TextView) view.findViewById(R.id.repository); + size = (TextView) view.findViewById(R.id.size); + api = (TextView) view.findViewById(R.id.api); + incompatibleReasons = (TextView) view.findViewById(R.id.incompatible_reasons); + buildtype = (TextView) view.findViewById(R.id.buildtype); + added = (TextView) view.findViewById(R.id.added); + nativecode = (TextView) view.findViewById(R.id.nativecode); + + int margin = context.getResources().getDimensionPixelSize(R.dimen.layout_horizontal_margin); + int padding = context.getResources().getDimensionPixelSize(R.dimen.details_activity_padding); + ViewCompat.setPaddingRelative(view, margin + padding + ViewCompat.getPaddingStart(view), view.getPaddingTop(), ViewCompat.getPaddingEnd(view), view.getPaddingBottom()); + } + + public void bindModel(final Apk apk) { + java.text.DateFormat df = DateFormat.getDateFormat(context); + + version.setText(context.getString(R.string.version) + + " " + apk.versionName + + (apk.versionCode == app.suggestedVersionCode ? " ☆" : "")); + + status.setText(getInstalledStatus(apk)); + + repository.setText(context.getString(R.string.repo_provider, + RepoProvider.Helper.findById(context, apk.repo).getName())); + + if (apk.size > 0) { + size.setText(Utils.getFriendlySize(apk.size)); + size.setVisibility(View.VISIBLE); + } else { + size.setVisibility(View.GONE); + } + + if (!Preferences.get().expertMode()) { + api.setVisibility(View.GONE); + } else if (apk.minSdkVersion > 0 && apk.maxSdkVersion < Apk.SDK_VERSION_MAX_VALUE) { + api.setText(context.getString(R.string.minsdk_up_to_maxsdk, + Utils.getAndroidVersionName(apk.minSdkVersion), + Utils.getAndroidVersionName(apk.maxSdkVersion))); + api.setVisibility(View.VISIBLE); + } else if (apk.minSdkVersion > 0) { + api.setText(context.getString(R.string.minsdk_or_later, + Utils.getAndroidVersionName(apk.minSdkVersion))); + api.setVisibility(View.VISIBLE); + } else if (apk.maxSdkVersion > 0) { + api.setText(context.getString(R.string.up_to_maxsdk, + Utils.getAndroidVersionName(apk.maxSdkVersion))); + api.setVisibility(View.VISIBLE); + } + + if (apk.srcname != null) { + buildtype.setText("source"); + } else { + buildtype.setText("bin"); + } + + if (apk.added != null) { + added.setText(context.getString(R.string.added_on, + df.format(apk.added))); + added.setVisibility(View.VISIBLE); + } else { + added.setVisibility(View.GONE); + } + + if (Preferences.get().expertMode() && apk.nativecode != null) { + nativecode.setText(TextUtils.join(" ", apk.nativecode)); + nativecode.setVisibility(View.VISIBLE); + } else { + nativecode.setVisibility(View.GONE); + } + + if (apk.incompatibleReasons != null) { + incompatibleReasons.setText( + context.getResources().getString( + R.string.requires_features, + TextUtils.join(", ", apk.incompatibleReasons))); + incompatibleReasons.setVisibility(View.VISIBLE); + } else { + incompatibleReasons.setVisibility(View.GONE); + } + + // Disable it all if it isn't compatible... + final View[] views = { + itemView, + version, + status, + repository, + size, + api, + buildtype, + added, + nativecode, + }; + for (final View v : views) { + v.setEnabled(apk.compatible); + } + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.installApk(apk); + } + }); + } + } + + private void addLinkItemView(ViewGroup parent, int resIdText, int resIdDrawable, final String url) { + TextView view = (TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.app_details2_link_item, parent, false); + view.setText(resIdText); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(view, resIdDrawable, 0, 0, 0); + parent.addView(view); + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onLinkClicked(url); + } + }); + } + + private String getInstalledStatus(final Apk apk) { + // Definitely not installed. + if (apk.versionCode != app.installedVersionCode) { + return context.getString(R.string.app_not_installed); + } + // Definitely installed this version. + if (apk.sig != null && apk.sig.equals(app.installedSig)) { + return context.getString(R.string.app_installed); + } + // Installed the same version, but from someplace else. + final String installerPkgName; + try { + installerPkgName = context.getPackageManager().getInstallerPackageName(app.packageName); + } catch (IllegalArgumentException e) { + Log.w("AppDetailsAdapter", "Application " + app.packageName + " is not installed anymore"); + return context.getString(R.string.app_not_installed); + } + if (TextUtils.isEmpty(installerPkgName)) { + return context.getString(R.string.app_inst_unknown_source); + } + final String installerLabel = InstalledAppProvider + .getApplicationLabel(context, installerPkgName); + return context.getString(R.string.app_inst_known_source, installerLabel); + } + + private void onLinkClicked(String url) { + if (!TextUtils.isEmpty(url)) { + callbacks.openUrl(url); + } + } + + private final View.OnClickListener onInstallClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.installApk(); + } + }; + + private final View.OnClickListener onUnInstallClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.uninstallApk(); + } + }; + + private final View.OnClickListener onUpgradeClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.upgradeApk(); + } + }; + + private final View.OnClickListener onLaunchClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + callbacks.launchApk(); + } + }; + + private boolean uriIsSetAndCanBeOpened(String s) { + if (TextUtils.isEmpty(s)) { + return false; + } + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(s)); + return intent.resolveActivity(context.getPackageManager()) != null; + } + + /** + * The HTML formatter adds "\n\n" at the end of every paragraph. This + * is desired between paragraphs, but not at the end of the whole + * string as it adds unwanted spacing at the end of the TextView. + * Remove all trailing newlines. + * Use this function instead of a trim() as that would require + * converting to String and thus losing formatting (e.g. bold). + */ + public static CharSequence trimTrailingNewlines(CharSequence s) { + if (TextUtils.isEmpty(s)) { + return s; + } + int i; + for (i = s.length() - 1; i >= 0; i--) { + if (s.charAt(i) != '\n') { + break; + } + } + if (i == s.length() - 1) { + return s; + } + return s.subSequence(0, i + 1); + } + + @SuppressLint("ParcelCreator") + private static final class SafeURLSpan extends URLSpan { + SafeURLSpan(String url) { + super(url); + } + + @Override + public void onClick(View widget) { + try { + super.onClick(widget); + } catch (ActivityNotFoundException ex) { + Toast.makeText(widget.getContext(), + widget.getContext().getString(R.string.no_handler_app, getURL()), + Toast.LENGTH_LONG).show(); + } + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppListAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppListAdapter.java index 17149c6a0..98cfac28c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppListAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppListAdapter.java @@ -18,7 +18,7 @@ import org.fdroid.fdroid.data.App; public abstract class AppListAdapter extends CursorAdapter { - private LayoutInflater mInflater; + private LayoutInflater inflater; private DisplayImageOptions displayImageOptions; private String upgradeFromTo; @@ -44,7 +44,7 @@ public abstract class AppListAdapter extends CursorAdapter { } private void init(Context context) { - mInflater = (LayoutInflater) context.getSystemService( + inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE); displayImageOptions = Utils.getImageLoadingOptions().build(); upgradeFromTo = context.getResources().getString(R.string.upgrade_from_to); @@ -64,7 +64,7 @@ public abstract class AppListAdapter extends CursorAdapter { @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { - View view = mInflater.inflate(R.layout.applistitem, parent, false); + View view = inflater.inflate(R.layout.applistitem, parent, false); ViewHolder holder = new ViewHolder(); holder.name = (TextView) view.findViewById(R.id.name); diff --git a/app/src/main/java/org/fdroid/fdroid/views/LinearLayoutManagerSnapHelper.java b/app/src/main/java/org/fdroid/fdroid/views/LinearLayoutManagerSnapHelper.java new file mode 100644 index 000000000..a461c4416 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/LinearLayoutManagerSnapHelper.java @@ -0,0 +1,139 @@ +package org.fdroid.fdroid.views; + +import android.support.annotation.NonNull; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.LinearSnapHelper; +import android.support.v7.widget.OrientationHelper; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +public class LinearLayoutManagerSnapHelper extends LinearSnapHelper { + + private View lastSavedTarget; + private int lastSavedDistance; + + public interface LinearSnapHelperListener { + /** + * Tells the listener that we have selected a view to snap to. + * @param view The selected view (may be null) + * @param position Adapter position of the snapped to view (or NO_POSITION if none) + */ + void onSnappedToView(View view, int position); + } + + private final LinearLayoutManager layoutManager; + private final OrientationHelper orientationHelper; + private LinearSnapHelperListener listener; + + public LinearLayoutManagerSnapHelper(LinearLayoutManager layoutManager) { + this.layoutManager = layoutManager; + this.orientationHelper = OrientationHelper.createHorizontalHelper(this.layoutManager); + } + + public void setLinearSnapHelperListener(LinearSnapHelperListener listener) { + this.listener = listener; + } + + @Override + public View findSnapView(RecyclerView.LayoutManager layoutManager) { + View snappedView = super.findSnapView(layoutManager); + if (snappedView != null && layoutManager.canScrollHorizontally()) { + if (layoutManager instanceof LinearLayoutManager) { + + // The super class implementation will always try to snap the center of a view to the + // center of the screen. This is desired behavior, but will result in that the first + // and last item will never be fully visible (unless in the special case when they all + // fit on the screen) + // + // We handle this by checking if the first (and/or the last) item is visible, and compare + // the distance it would take to "snap" this item to the screen edge to the distance + // needed to snap the "snappedView" to the center of the screen. We always go for the + // smallest distance, e.g. the closest snap position. + // + // To further complicate this, we might have intermediate views in the range 1..idxSnap + // (and correspondingly idxsnap+1..idxLast-1) that will never be "snapped to". We + // interpolate the "snap position" for these views (between center screen and screen edge) + // and then calculate the snap distance for them, again selecting the smallest of them all. + lastSavedTarget = null; + + int centerSnapPosition = orientationHelper.getTotalSpace() / 2; + + int firstChild = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition(); + int lastChild = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition(); + + int currentSmallestDistance = Integer.MAX_VALUE; + View currentSmallestDistanceView = null; + + int snappedViewIndex = ((LinearLayoutManager) layoutManager).getPosition(snappedView); + if (snappedViewIndex != RecyclerView.NO_POSITION) { + + int snapPositionFirst = orientationHelper.getDecoratedMeasurement(((LinearLayoutManager) layoutManager).findViewByPosition(firstChild)) / 2; + int snapPositionLast = orientationHelper.getTotalSpace() - orientationHelper.getDecoratedMeasurement(((LinearLayoutManager) layoutManager).findViewByPosition(lastChild)) / 2; + + // If first item not on screen, ignore views 0..snappedViewIndex-1 + if (firstChild != 0) { + firstChild = snappedViewIndex; + } + + // If last item not on screen, ignore views snappedViewIndex+1..N + if (lastChild != this.layoutManager.getItemCount() - 1) { + lastChild = snappedViewIndex; + } + + for (int i = firstChild; i <= lastChild; i++) { + View view = ((LinearLayoutManager) layoutManager).findViewByPosition(i); + + // Start by interpolating a snap position for (the center of) this view. + // + int snapPosition; + if (i == snappedViewIndex) { + snapPosition = centerSnapPosition; + } else if (i > snappedViewIndex) { + snapPosition = snapPositionLast - (lastChild - i) * (snapPositionLast - centerSnapPosition) / (lastChild - snappedViewIndex); + } else { + snapPosition = snapPositionFirst + (i - firstChild) * (centerSnapPosition - snapPositionFirst) / (snappedViewIndex - firstChild); + } + + // Get current position of view (center) + // + int viewPosition = view.getLeft() + view.getWidth() / 2; + + // Calculate distance and compare to current best candidate + // + int dist = snapPosition - viewPosition; + if (Math.abs(dist) < Math.abs(currentSmallestDistance) || (Math.abs(dist) == Math.abs(currentSmallestDistance))) { + currentSmallestDistance = dist; + currentSmallestDistanceView = view; + } + } + + // Update with best snap candidate + snappedView = currentSmallestDistanceView; + lastSavedTarget = currentSmallestDistanceView; + lastSavedDistance = -currentSmallestDistance; + } + } + } + if (listener != null) { + int snappedPosition = 0; + if (snappedView != null) { + snappedPosition = this.layoutManager.getPosition(snappedView); + } + listener.onSnappedToView(snappedView, snappedPosition); + } + return snappedView; + } + + @Override + public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { + if (targetView == lastSavedTarget) { + // No need to recalc, we already did this when finding the snap candidate + // + int[] out = new int[2]; + out[0] = lastSavedDistance; + out[1] = 0; + return out; + } + return super.calculateDistanceToFinalSnap(layoutManager, targetView); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java new file mode 100644 index 000000000..0bd883a03 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java @@ -0,0 +1,102 @@ +package org.fdroid.fdroid.views; + +import android.content.Context; +import android.graphics.Bitmap; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; + +public class ScreenShotsRecyclerViewAdapter extends RecyclerView.Adapter implements LinearLayoutManagerSnapHelper.LinearSnapHelperListener { + private final App app; + private final DisplayImageOptions displayImageOptions; + private View selectedView; + private int selectedPosition; + private final int selectedItemElevation; + private final int unselectedItemMargin; + + public ScreenShotsRecyclerViewAdapter(Context context, App app) { + super(); + this.app = app; + selectedPosition = 0; + selectedItemElevation = context.getResources().getDimensionPixelSize(R.dimen.details_screenshot_selected_elevation); + unselectedItemMargin = context.getResources().getDimensionPixelSize(R.dimen.details_screenshot_margin); + 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(); + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + ScreenShotViewHolder vh = (ScreenShotViewHolder) holder; + setViewSelected(vh.itemView, position == selectedPosition); + if (position == selectedPosition) { + this.selectedView = vh.itemView; + } + ImageLoader.getInstance().displayImage(app.iconUrlLarge, vh.image, displayImageOptions); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.app_details2_screenshot_item, parent, false); + return new ScreenShotViewHolder(view); + } + + @Override + public int getItemCount() { + return 7; + } + + @Override + public void onSnappedToView(View view, int snappedPosition) { + // Deselect the previous selected view first + setViewSelected(selectedView, false); + + // Change the selected view to the newly snapped-to view. + selectedView = view; + selectedPosition = snappedPosition; + setViewSelected(selectedView, true); + } + + private void setViewSelected(View view, boolean selected) { + if (view != null) { + RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams(); + if (selected) { + lp.setMargins(0, selectedItemElevation, 0, selectedItemElevation); + } else { + lp.setMargins(0, unselectedItemMargin, 0, unselectedItemMargin); + } + ViewCompat.setElevation(view, selected ? selectedItemElevation : selectedItemElevation / 2); + view.setLayoutParams(lp); + } + } + + private class ScreenShotViewHolder extends RecyclerView.ViewHolder { + final ImageView image; + + ScreenShotViewHolder(View view) { + super(view); + image = (ImageView) view.findViewById(R.id.image); + } + + @Override + public String toString() { + return super.toString() + " screenshot"; + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/ShareChooserDialog.java b/app/src/main/java/org/fdroid/fdroid/views/ShareChooserDialog.java new file mode 100644 index 000000000..9a83b4908 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/ShareChooserDialog.java @@ -0,0 +1,213 @@ +package org.fdroid.fdroid.views; + +import android.app.Dialog; +import android.content.ComponentName; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.BottomSheetDialogFragment; +import android.support.design.widget.CoordinatorLayout; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.fdroid.fdroid.BuildConfig; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; + +import java.util.ArrayList; +import java.util.List; + +public class ShareChooserDialog extends BottomSheetDialogFragment { + private static final String ARG_WIDTH = "width"; + private static final String ARG_INTENT = "intent"; + private static final String ARG_SHOW_NEARBY = "showNearby"; + + private static final int VIEWTYPE_SWAP = 1; + private static final int VIEWTYPE_INTENT = 0; + + private RecyclerView recyclerView; + private ArrayList targets; + private int parentWidth; + private Intent shareIntent; + private boolean showNearby; + + public interface ShareChooserDialogListener { + + void onNearby(); + + void onResolvedShareIntent(Intent shareIntent); + + } + + private ShareChooserDialogListener listener; + + private void setListener(ShareChooserDialogListener listener) { + this.listener = listener; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + parentWidth = getArguments().getInt(ARG_WIDTH, 640); + shareIntent = getArguments().getParcelable(ARG_INTENT); + showNearby = getArguments().getBoolean(ARG_SHOW_NEARBY, false); + targets = new ArrayList<>(); + List resInfo = getContext().getPackageManager().queryIntentActivities(shareIntent, 0); + if (resInfo != null && resInfo.size() > 0) { + for (ResolveInfo resolveInfo : resInfo) { + String packageName = resolveInfo.activityInfo.packageName; + if (!packageName.equals(BuildConfig.APPLICATION_ID)) { // Remove ourselves + targets.add(resolveInfo); + } + } + } + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.setOnShowListener(new DialogInterface.OnShowListener() { + @Override + public void onShow(DialogInterface dialogInterface) { + dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + dialog.getWindow().setLayout( + parentWidth - Utils.dpToPx(0, getContext()), // Set margins here! + ViewGroup.LayoutParams.MATCH_PARENT); + } + }); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.share_chooser, container, false); + setupView(v); + return v; + } + + private void setupView(View v) { + recyclerView = (RecyclerView) v.findViewById(R.id.recycler_view_apps); + + // Figure out how many columns that fit in the given parent width. Give them 100dp. + int appWidth = Utils.dpToPx(80, getContext()); + final int nCols = (parentWidth - /* padding */ Utils.dpToPx(8, getContext())) / appWidth; + GridLayoutManager glm = new GridLayoutManager(getContext(), nCols); + + // Ensure that if available, the "Nearby Swap" item spans the entire width. + glm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + if (recyclerView.getAdapter() != null) { + if (recyclerView.getAdapter().getItemViewType(position) == VIEWTYPE_SWAP) { + return nCols; + } + return 1; + } + return 0; + } + }); + recyclerView.setLayoutManager(glm); + + class VH extends RecyclerView.ViewHolder { + public final ImageView icon; + public final TextView label; + + VH(View itemView) { + super(itemView); + icon = (ImageView) itemView.findViewById(R.id.ivShare); + label = (TextView) itemView.findViewById(R.id.tvShare); + } + } + + recyclerView.setAdapter(new RecyclerView.Adapter() { + + private ArrayList intents; + + RecyclerView.Adapter init(List targetedShareIntents) { + intents = new ArrayList<>(); + if (showNearby) { + intents.add(null); + } + for (ResolveInfo ri : targetedShareIntents) { + intents.add(ri); + } + return this; + } + + @Override + public int getItemViewType(int position) { + if (intents.get(position) == null) { + return VIEWTYPE_SWAP; + } + return VIEWTYPE_INTENT; + } + + @Override + public VH onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate((viewType == 1) ? R.layout.share_header_item : R.layout.share_item, parent, false); + return new VH(view); + } + + @Override + public void onBindViewHolder(VH holder, int position) { + if (getItemViewType(position) == VIEWTYPE_SWAP) { + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (listener != null) { + listener.onNearby(); + } + dismiss(); + } + }); + return; + } + final ResolveInfo ri = intents.get(position); + holder.icon.setImageDrawable(ri.loadIcon(getContext().getPackageManager())); + holder.label.setText(ri.loadLabel(getContext().getPackageManager())); + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (listener != null) { + Intent intent = new Intent(shareIntent); + ComponentName name = new ComponentName(ri.activityInfo.applicationInfo.packageName, + ri.activityInfo.name); + intent.setComponent(name); + listener.onResolvedShareIntent(intent); + } + dismiss(); + } + }); + } + + @Override + public int getItemCount() { + return intents.size(); + } + }.init(targets)); + + } + + public static void createChooser(CoordinatorLayout rootView, ShareChooserDialog.ShareChooserDialogListener listener, final AppCompatActivity parent, final Intent shareIntent, boolean showNearbyItem) { + ShareChooserDialog d = new ShareChooserDialog(); + d.setListener(listener); + Bundle args = new Bundle(); + args.putInt(ARG_WIDTH, rootView.getWidth()); + args.putParcelable(ARG_INTENT, shareIntent); + args.putBoolean(ARG_SHOW_NEARBY, showNearbyItem); + d.setArguments(args); + d.show(parent.getSupportFragmentManager(), "Share"); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/AppListFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/AppListFragment.java index 188861f01..702b2470d 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/fragments/AppListFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/AppListFragment.java @@ -20,6 +20,7 @@ import android.widget.AdapterView; import android.widget.TextView; import org.fdroid.fdroid.AppDetails; +import org.fdroid.fdroid.AppDetails2; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.UpdateService; @@ -30,6 +31,7 @@ import org.fdroid.fdroid.views.AppListAdapter; public abstract class AppListFragment extends ListFragment implements AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener, Preferences.ChangeListener, LoaderManager.LoaderCallbacks { @@ -109,6 +111,7 @@ public abstract class AppListFragment extends ListFragment implements // returns the list view is "called between onCreate and // onActivityCreated" according to the docs. getListView().setOnItemClickListener(this); + getListView().setOnItemLongClickListener(this); } @Override @@ -155,11 +158,21 @@ public abstract class AppListFragment extends ListFragment implements @Override public void onItemClick(AdapterView parent, View view, int position, long id) { + showItemDetails(view, position, false); + } + + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { + showItemDetails(view, position, true); + return true; + } + + private void showItemDetails(View view, int position, boolean useNewDetailsActivity) { // Cursor is null in the swap list when touching the first item. Cursor cursor = (Cursor) getListView().getItemAtPosition(position); if (cursor != null) { final App app = new App(cursor); - Intent intent = getAppDetailsIntent(); + Intent intent = getAppDetailsIntent(useNewDetailsActivity); intent.putExtra(AppDetails.EXTRA_APPID, app.packageName); intent.putExtra(AppDetails.EXTRA_FROM, getFromTitle()); if (Build.VERSION.SDK_INT >= 21) { @@ -176,8 +189,8 @@ public abstract class AppListFragment extends ListFragment implements } } - private Intent getAppDetailsIntent() { - return new Intent(getActivity(), AppDetails.class); + private Intent getAppDetailsIntent(boolean useNewDetailsActivity) { + return new Intent(getActivity(), useNewDetailsActivity ? AppDetails2.class : AppDetails.class); } @Override diff --git a/app/src/main/java/org/fdroid/fdroid/views/swap/SelectAppsView.java b/app/src/main/java/org/fdroid/fdroid/views/swap/SelectAppsView.java index 33b0a519e..9f5112755 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/swap/SelectAppsView.java +++ b/app/src/main/java/org/fdroid/fdroid/views/swap/SelectAppsView.java @@ -71,7 +71,7 @@ public class SelectAppsView extends ListView implements private static final int LOADER_INSTALLED_APPS = 253341534; private AppListAdapter adapter; - private String mCurrentFilterString; + private String currentFilterString; @Override protected void onFinishInflate() { @@ -154,10 +154,10 @@ public class SelectAppsView extends ListView implements @Override public CursorLoader onCreateLoader(int id, Bundle args) { Uri uri; - if (TextUtils.isEmpty(mCurrentFilterString)) { + if (TextUtils.isEmpty(currentFilterString)) { uri = InstalledAppProvider.getContentUri(); } else { - uri = InstalledAppProvider.getSearchUri(mCurrentFilterString); + uri = InstalledAppProvider.getSearchUri(currentFilterString); } return new CursorLoader( getActivity(), @@ -192,13 +192,13 @@ public class SelectAppsView extends ListView implements @Override public boolean onQueryTextChange(String newText) { String newFilter = !TextUtils.isEmpty(newText) ? newText : null; - if (mCurrentFilterString == null && newFilter == null) { + if (currentFilterString == null && newFilter == null) { return true; } - if (mCurrentFilterString != null && mCurrentFilterString.equals(newFilter)) { + if (currentFilterString != null && currentFilterString.equals(newFilter)) { return true; } - mCurrentFilterString = newFilter; + currentFilterString = newFilter; getActivity().getSupportLoaderManager().restartLoader(LOADER_INSTALLED_APPS, null, this); return true; } diff --git a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java index a252b76a2..49f600d6b 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java +++ b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java @@ -89,7 +89,7 @@ public class SwapAppsView extends ListView implements private Repo repo; private AppListAdapter adapter; - private String mCurrentFilterString; + private String currentFilterString; @Override protected void onFinishInflate() { @@ -190,9 +190,9 @@ public class SwapAppsView extends ListView implements @Override public CursorLoader onCreateLoader(int id, Bundle args) { - Uri uri = TextUtils.isEmpty(mCurrentFilterString) + Uri uri = TextUtils.isEmpty(currentFilterString) ? AppProvider.getRepoUri(repo) - : AppProvider.getSearchUri(repo, mCurrentFilterString); + : AppProvider.getSearchUri(repo, currentFilterString); return new CursorLoader(getActivity(), uri, AppMetadataTable.Cols.ALL, null, null, AppMetadataTable.Cols.NAME); } @@ -210,13 +210,13 @@ public class SwapAppsView extends ListView implements @Override public boolean onQueryTextChange(String newText) { String newFilter = !TextUtils.isEmpty(newText) ? newText : null; - if (mCurrentFilterString == null && newFilter == null) { + if (currentFilterString == null && newFilter == null) { return true; } - if (mCurrentFilterString != null && mCurrentFilterString.equals(newFilter)) { + if (currentFilterString != null && currentFilterString.equals(newFilter)) { return true; } - mCurrentFilterString = newFilter; + currentFilterString = newFilter; getActivity().getSupportLoaderManager().restartLoader(LOADER_SWAPABLE_APPS, null, this); return true; } diff --git a/app/src/main/res/drawable-hdpi/ic_access_time_24dp_grey600.png b/app/src/main/res/drawable-hdpi/ic_access_time_24dp_grey600.png new file mode 100644 index 000000000..df0f4a599 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_access_time_24dp_grey600.png differ diff --git a/app/src/main/res/drawable-mdpi/feature_placeholder.png b/app/src/main/res/drawable-mdpi/feature_placeholder.png new file mode 100644 index 000000000..861559285 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/feature_placeholder.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_access_time_24dp_grey600.png b/app/src/main/res/drawable-mdpi/ic_access_time_24dp_grey600.png new file mode 100644 index 000000000..a92484fbe Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_access_time_24dp_grey600.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_access_time_24dp_grey600.png b/app/src/main/res/drawable-xhdpi/ic_access_time_24dp_grey600.png new file mode 100644 index 000000000..cb1c81d7a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_access_time_24dp_grey600.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_access_time_24dp_grey600.png b/app/src/main/res/drawable-xxhdpi/ic_access_time_24dp_grey600.png new file mode 100644 index 000000000..46842ea0c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_access_time_24dp_grey600.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_access_time_24dp_grey600.png b/app/src/main/res/drawable-xxxhdpi/ic_access_time_24dp_grey600.png new file mode 100644 index 000000000..48d681580 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_access_time_24dp_grey600.png differ diff --git a/app/src/main/res/drawable/button_primary_background_selector.xml b/app/src/main/res/drawable/button_primary_background_selector.xml new file mode 100644 index 000000000..ee21a1543 --- /dev/null +++ b/app/src/main/res/drawable/button_primary_background_selector.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_secondary_background_selector.xml b/app/src/main/res/drawable/button_secondary_background_selector.xml new file mode 100644 index 000000000..2cb0d8ebb --- /dev/null +++ b/app/src/main/res/drawable/button_secondary_background_selector.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/details_panel_light_background.xml b/app/src/main/res/drawable/details_panel_light_background.xml new file mode 100644 index 000000000..8ccc278ab --- /dev/null +++ b/app/src/main/res/drawable/details_panel_light_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nearby_share.xml b/app/src/main/res/drawable/ic_nearby_share.xml new file mode 100644 index 000000000..9aba68fa7 --- /dev/null +++ b/app/src/main/res/drawable/ic_nearby_share.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/layout/app_details2.xml b/app/src/main/res/layout/app_details2.xml new file mode 100644 index 000000000..31b441b7e --- /dev/null +++ b/app/src/main/res/layout/app_details2.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/app_details2_donate.xml b/app/src/main/res/layout/app_details2_donate.xml new file mode 100644 index 000000000..b50d20924 --- /dev/null +++ b/app/src/main/res/layout/app_details2_donate.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml new file mode 100755 index 000000000..5cba0bfe9 --- /dev/null +++ b/app/src/main/res/layout/app_details2_header.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + +