diff --git a/app/build.gradle b/app/build.gradle index 65913a79c..094e1aea2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -184,6 +184,7 @@ android { versionCode 102050 versionName getVersionName() testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true } testOptions { diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails.java b/app/src/main/java/org/fdroid/fdroid/AppDetails.java index 8ff6ee17b..15e0539e4 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails.java @@ -432,6 +432,12 @@ public class AppDetails extends AppCompatActivity { myAppObserver); } + @Override + protected void onResume() { + super.onResume(); + updateNotificationsForApp(); + } + @Override protected void onResumeFragments() { // Must be called before super.onResumeFragments(), as the fragments depend on the active @@ -462,13 +468,34 @@ public class AppDetails extends AppCompatActivity { protected void onStop() { super.onStop(); + visiblePackageName = null; getContentResolver().unregisterContentObserver(myAppObserver); + + // When leaving the app details, make sure to refresh app status for this app, since + // we might want to show notifications for it now. + updateNotificationsForApp(); + } + + /** + * Some notifications (like "downloading" and "installed") are not shown for this app if it is open in app details. + * When closing, we need to refresh the notifications, so they are displayed again. + */ + private void updateNotificationsForApp() { + if (app != null) { + AppUpdateStatusManager appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); + for (AppUpdateStatusManager.AppUpdateStatus status : appUpdateStatusManager.getByPackageName(app.packageName)) { + if (status.status == AppUpdateStatusManager.Status.Installed) { + appUpdateStatusManager.removeApk(status.getUniqueKey()); + } else { + appUpdateStatusManager.refreshApk(status.getUniqueKey()); + } + } + } } @Override protected void onPause() { super.onPause(); - visiblePackageName = null; // save the active URL for this app in case we come back getPreferences(MODE_PRIVATE) .edit() @@ -554,7 +581,7 @@ public class AppDetails extends AppCompatActivity { String errorMessage = intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); - if (!TextUtils.isEmpty(errorMessage)) { + if (!TextUtils.isEmpty(errorMessage) && !isFinishing()) { Log.e(TAG, "install aborted with errorMessage: " + errorMessage); String title = String.format( diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java index 52e06311f..96686b35a 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java @@ -64,6 +64,15 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog private LocalBroadcastManager localBroadcastManager; private String activeDownloadUrlString; + /** + * Check if {@code packageName} is currently visible to the user. + */ + public static boolean isAppVisible(String packageName) { + return packageName != null && packageName.equals(visiblePackageName); + } + + private static String visiblePackageName; + @Override protected void onCreate(Bundle savedInstanceState) { fdroidApp = (FDroidApp) getApplication(); @@ -121,6 +130,49 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog return; } app = newApp; + + // Remove all "installed" statuses for this app, since we are now viewing it. + AppUpdateStatusManager appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); + for (AppUpdateStatusManager.AppUpdateStatus status : appUpdateStatusManager.getByPackageName(app.packageName)) { + if (status.status == AppUpdateStatusManager.Status.Installed) { + appUpdateStatusManager.removeApk(status.getUniqueKey()); + } + } + } + + /** + * Some notifications (like "downloading" and "installed") are not shown for this app if it is open in app details. + * When closing, we need to refresh the notifications, so they are displayed again. + */ + private void updateNotificationsForApp() { + if (app != null) { + AppUpdateStatusManager appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); + for (AppUpdateStatusManager.AppUpdateStatus status : appUpdateStatusManager.getByPackageName(app.packageName)) { + if (status.status == AppUpdateStatusManager.Status.Installed) { + appUpdateStatusManager.removeApk(status.getUniqueKey()); + } else { + appUpdateStatusManager.refreshApk(status.getUniqueKey()); + } + } + } + } + + @Override + protected void onResume() { + super.onResume(); + if (app != null) { + visiblePackageName = app.packageName; + } + updateNotificationsForApp(); + } + + protected void onStop() { + super.onStop(); + visiblePackageName = null; + + // When leaving the app details, make sure to refresh app status for this app, since + // we might want to show notifications for it now. + updateNotificationsForApp(); } @Override @@ -364,7 +416,7 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog String errorMessage = intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); - if (!TextUtils.isEmpty(errorMessage)) { + if (!TextUtils.isEmpty(errorMessage) && !isFinishing()) { Log.e(TAG, "install aborted with errorMessage: " + errorMessage); String title = String.format( diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java new file mode 100644 index 000000000..8ca9fa56d --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -0,0 +1,378 @@ +package org.fdroid.fdroid; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.TaskStackBuilder; +import android.support.v4.content.LocalBroadcastManager; + +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.installer.ErrorDialogActivity; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Manages the state of APKs that are being installed or that have updates available. + *

+ * The full URL for the APK file to download is used as the unique ID to + * represent the status of the APK throughout F-Droid. The full download URL is guaranteed + * to be unique since it points to files on a filesystem, where there cannot be multiple files with + * the same name. This provides a unique ID beyond just {@code packageName} + * and {@code versionCode} since there could be different copies of the same + * APK on different servers, signed by different keys, or even different builds. + */ +public final class AppUpdateStatusManager { + + static final String BROADCAST_APPSTATUS_LIST_CHANGED = "org.fdroid.fdroid.installer.appstatus.listchange"; + static final String BROADCAST_APPSTATUS_ADDED = "org.fdroid.fdroid.installer.appstatus.appchange.add"; + static final String BROADCAST_APPSTATUS_CHANGED = "org.fdroid.fdroid.installer.appstatus.appchange.change"; + static final String BROADCAST_APPSTATUS_REMOVED = "org.fdroid.fdroid.installer.appstatus.appchange.remove"; + static final String EXTRA_APK_URL = "urlstring"; + static final String EXTRA_IS_STATUS_UPDATE = "isstatusupdate"; + + private static final String LOGTAG = "AppUpdateStatusManager"; + + public enum Status { + Unknown, + UpdateAvailable, + Downloading, + ReadyToInstall, + Installing, + Installed, + InstallError + } + + public static AppUpdateStatusManager getInstance(Context context) { + if (instance == null) { + instance = new AppUpdateStatusManager(context.getApplicationContext()); + } + return instance; + } + + private static AppUpdateStatusManager instance; + + public class AppUpdateStatus { + public final App app; + public final Apk apk; + public Status status; + public PendingIntent intent; + public int progressCurrent; + public int progressMax; + public String errorText; + + AppUpdateStatus(App app, Apk apk, Status status, PendingIntent intent) { + this.app = app; + this.apk = apk; + this.status = status; + this.intent = intent; + } + + public String getUniqueKey() { + return apk.getUrl(); + } + } + + private final Context context; + private final LocalBroadcastManager localBroadcastManager; + private final HashMap appMapping = new HashMap<>(); + private boolean isBatchUpdating; + + private AppUpdateStatusManager(Context context) { + this.context = context; + localBroadcastManager = LocalBroadcastManager.getInstance(context.getApplicationContext()); + } + + @Nullable + public AppUpdateStatus get(String key) { + synchronized (appMapping) { + return appMapping.get(key); + } + } + + public Collection getAll() { + synchronized (appMapping) { + return appMapping.values(); + } + } + + /** + * Get all entries associated with a package name. There may be several. + * @param packageName Package name of the app + * @return A list of entries, or an empty list + */ + public Collection getByPackageName(String packageName) { + ArrayList returnValues = new ArrayList<>(); + synchronized (appMapping) { + for (AppUpdateStatus entry : appMapping.values()) { + if (entry.apk.packageName.equalsIgnoreCase(packageName)) { + returnValues.add(entry); + } + } + } + return returnValues; + } + + private void updateApkInternal(@NonNull AppUpdateStatus entry, @NonNull Status status, PendingIntent intent) { + Utils.debugLog(LOGTAG, "Update APK " + entry.apk.apkName + " state to " + status.name()); + boolean isStatusUpdate = entry.status != status; + entry.status = status; + entry.intent = intent; + // If intent not set, see if we need to create a default intent + if (entry.intent == null) { + entry.intent = getContentIntent(entry); + } + notifyChange(entry, isStatusUpdate); + } + + private void addApkInternal(@NonNull Apk apk, @NonNull Status status, PendingIntent intent) { + Utils.debugLog(LOGTAG, "Add APK " + apk.apkName + " with state " + status.name()); + AppUpdateStatus entry = createAppEntry(apk, status, intent); + // If intent not set, see if we need to create a default intent + if (entry.intent == null) { + entry.intent = getContentIntent(entry); + } + appMapping.put(entry.getUniqueKey(), entry); + notifyAdd(entry); + } + + private void notifyChange() { + if (!isBatchUpdating) { + localBroadcastManager.sendBroadcast(new Intent(BROADCAST_APPSTATUS_LIST_CHANGED)); + } + } + + private void notifyAdd(AppUpdateStatus entry) { + if (!isBatchUpdating) { + Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_ADDED); + broadcastIntent.putExtra(EXTRA_APK_URL, entry.getUniqueKey()); + localBroadcastManager.sendBroadcast(broadcastIntent); + } + } + + private void notifyChange(AppUpdateStatus entry, boolean isStatusUpdate) { + if (!isBatchUpdating) { + Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_CHANGED); + broadcastIntent.putExtra(EXTRA_APK_URL, entry.getUniqueKey()); + broadcastIntent.putExtra(EXTRA_IS_STATUS_UPDATE, isStatusUpdate); + localBroadcastManager.sendBroadcast(broadcastIntent); + } + } + + private void notifyRemove(AppUpdateStatus entry) { + if (!isBatchUpdating) { + Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_REMOVED); + broadcastIntent.putExtra(EXTRA_APK_URL, entry.getUniqueKey()); + localBroadcastManager.sendBroadcast(broadcastIntent); + } + } + + private AppUpdateStatus createAppEntry(Apk apk, Status status, PendingIntent intent) { + synchronized (appMapping) { + ContentResolver resolver = context.getContentResolver(); + App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo); + AppUpdateStatus ret = new AppUpdateStatus(app, apk, status, intent); + appMapping.put(apk.getUrl(), ret); + return ret; + } + } + + public void addApks(List apksToUpdate, Status status) { + startBatchUpdates(); + for (Apk apk : apksToUpdate) { + addApk(apk, status, null); + } + endBatchUpdates(); + } + + /** + * Add an Apk to the AppUpdateStatusManager manager (or update it if we already know about it). + * @param apk The apk to add. + * @param status The current status of the app + * @param pendingIntent Action when notification is clicked. Can be null for default action(s) + */ + public void addApk(Apk apk, @NonNull Status status, @Nullable PendingIntent pendingIntent) { + if (apk == null) { + return; + } + + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(apk.getUrl()); + if (entry != null) { + updateApkInternal(entry, status, pendingIntent); + } else { + addApkInternal(apk, status, pendingIntent); + } + } + } + + /** + * @param pendingIntent Action when notification is clicked. Can be null for default action(s) + */ + public void updateApk(String key, @NonNull Status status, @Nullable PendingIntent pendingIntent) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(key); + if (entry != null) { + updateApkInternal(entry, status, pendingIntent); + } + } + } + + @Nullable + public Apk getApk(String key) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(key); + if (entry != null) { + return entry.apk; + } + return null; + } + } + + public void removeApk(String key) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(key); + if (entry != null) { + Utils.debugLog(LOGTAG, "Remove APK " + entry.apk.apkName); + appMapping.remove(entry.apk.getUrl()); + notifyRemove(entry); + } + } + } + + public void refreshApk(String key) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(key); + if (entry != null) { + Utils.debugLog(LOGTAG, "Refresh APK " + entry.apk.apkName); + notifyChange(entry, true); + } + } + } + + public void updateApkProgress(String key, int max, int current) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(key); + if (entry != null) { + entry.progressMax = max; + entry.progressCurrent = current; + notifyChange(entry, false); + } + } + } + + public void setApkError(Apk apk, String errorText) { + synchronized (appMapping) { + AppUpdateStatus entry = appMapping.get(apk.getUrl()); + if (entry == null) { + entry = createAppEntry(apk, Status.InstallError, null); + } + entry.status = Status.InstallError; + entry.errorText = errorText; + entry.intent = getAppErrorIntent(entry); + notifyChange(entry, false); + } + } + + private void startBatchUpdates() { + synchronized (appMapping) { + isBatchUpdating = true; + } + } + + private void endBatchUpdates() { + synchronized (appMapping) { + isBatchUpdating = false; + notifyChange(); + } + } + + void clearAllUpdates() { + synchronized (appMapping) { + for (Iterator> it = appMapping.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + if (entry.getValue().status != Status.Installed) { + it.remove(); + } + } + notifyChange(); + } + } + + void clearAllInstalled() { + synchronized (appMapping) { + for (Iterator> it = appMapping.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + if (entry.getValue().status == Status.Installed) { + it.remove(); + } + } + notifyChange(); + } + } + + private PendingIntent getContentIntent(AppUpdateStatus entry) { + switch (entry.status) { + case UpdateAvailable: + case ReadyToInstall: + // Make sure we have an intent to install the app. If not set, we create an intent + // to open up the app details page for the app. From there, the user can hit "install" + return getAppDetailsIntent(entry.apk); + + case InstallError: + return getAppErrorIntent(entry); + + case Installed: + PackageManager pm = context.getPackageManager(); + Intent intentObject = pm.getLaunchIntentForPackage(entry.app.packageName); + if (intentObject != null) { + return PendingIntent.getActivity(context, 0, intentObject, 0); + } else { + // Could not get launch intent, maybe not launchable, e.g. a keyboard + return getAppDetailsIntent(entry.apk); + } + } + return null; + } + + /** + * Get a {@link PendingIntent} for a {@link Notification} to send when it + * is clicked. {@link AppDetails} handles {@code Intent}s that are missing + * or bad {@link AppDetails#EXTRA_APPID}, so it does not need to be checked + * here. + */ + private PendingIntent getAppDetailsIntent(Apk apk) { + Intent notifyIntent = new Intent(context, AppDetails.class) + .putExtra(AppDetails.EXTRA_APPID, apk.packageName); + + return TaskStackBuilder.create(context) + .addParentStack(AppDetails.class) + .addNextIntent(notifyIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private PendingIntent getAppErrorIntent(AppUpdateStatus entry) { + String title = String.format(context.getString(R.string.install_error_notify_title), entry.app.name); + + Intent errorDialogIntent = new Intent(context, ErrorDialogActivity.class) + .putExtra(ErrorDialogActivity.EXTRA_TITLE, title) + .putExtra(ErrorDialogActivity.EXTRA_MESSAGE, entry.errorText); + + return PendingIntent.getActivity( + context, + 0, + errorDialogIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index 7624f710b..67d773a63 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -37,6 +37,7 @@ import android.os.Build; import android.os.Environment; import android.os.StrictMode; import android.preference.PreferenceManager; +import android.support.v7.app.AppCompatDelegate; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; @@ -78,6 +79,10 @@ import sun.net.www.protocol.bluetooth.Handler; ) public class FDroidApp extends Application { + static { + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + } + private static final String TAG = "FDroidApp"; public static final String SYSTEM_DIR_NAME = Environment.getRootDirectory().getAbsolutePath(); @@ -98,6 +103,14 @@ public class FDroidApp extends Application { @SuppressWarnings("unused") BluetoothAdapter bluetoothAdapter; + /** + * The construction of this notification helper has side effects including listening and + * responding to local broadcasts. It is kept as a reference on the app object here so that + * it doesn't get GC'ed. + */ + @SuppressWarnings("unused") + NotificationHelper notificationHelper; + static { SPONGYCASTLE_PROVIDER = new org.spongycastle.jce.provider.BouncyCastleProvider(); enableSpongyCastle(); @@ -262,6 +275,7 @@ public class FDroidApp extends Application { CleanCacheService.schedule(this); + notificationHelper = new NotificationHelper(getApplicationContext()); UpdateService.schedule(getApplicationContext()); bluetoothAdapter = getBluetoothAdapter(); diff --git a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java new file mode 100644 index 000000000..ea89f6e66 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java @@ -0,0 +1,575 @@ +package org.fdroid.fdroid; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Point; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.support.graphics.drawable.VectorDrawableCompat; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.content.ContextCompat; +import android.support.v4.content.LocalBroadcastManager; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.StyleSpan; +import android.view.View; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import com.nostra13.universalimageloader.core.assist.ImageSize; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; +import com.nostra13.universalimageloader.utils.DiskCacheUtils; + +import org.fdroid.fdroid.data.App; + +import java.util.ArrayList; + +class NotificationHelper { + + private static final String BROADCAST_NOTIFICATIONS_ALL_UPDATES_CLEARED = "org.fdroid.fdroid.installer.notifications.allupdates.cleared"; + private static final String BROADCAST_NOTIFICATIONS_ALL_INSTALLED_CLEARED = "org.fdroid.fdroid.installer.notifications.allinstalled.cleared"; + private static final String BROADCAST_NOTIFICATIONS_UPDATE_CLEARED = "org.fdroid.fdroid.installer.notifications.update.cleared"; + private static final String BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED = "org.fdroid.fdroid.installer.notifications.installed.cleared"; + + private static final int NOTIFY_ID_UPDATES = 1; + private static final int NOTIFY_ID_INSTALLED = 2; + + private static final int MAX_UPDATES_TO_SHOW = 5; + private static final int MAX_INSTALLED_TO_SHOW = 10; + + private static final String EXTRA_NOTIFICATION_KEY = "key"; + private static final String GROUP_UPDATES = "updates"; + private static final String GROUP_INSTALLED = "installed"; + + private final Context context; + private final NotificationManagerCompat notificationManager; + private final AppUpdateStatusManager appUpdateStatusManager; + private final DisplayImageOptions displayImageOptions; + private final ArrayList updates = new ArrayList<>(); + private final ArrayList installed = new ArrayList<>(); + + NotificationHelper(Context context) { + this.context = context; + appUpdateStatusManager = AppUpdateStatusManager.getInstance(context); + notificationManager = NotificationManagerCompat.from(context); + displayImageOptions = new DisplayImageOptions.Builder() + .cacheInMemory(true) + .cacheOnDisk(true) + .imageScaleType(ImageScaleType.NONE) + .bitmapConfig(Bitmap.Config.RGB_565) + .build(); + + // We need to listen to when notifications are cleared, so that we "forget" all that we currently know about updates + // and installs. + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_NOTIFICATIONS_ALL_UPDATES_CLEARED); + filter.addAction(BROADCAST_NOTIFICATIONS_ALL_INSTALLED_CLEARED); + filter.addAction(BROADCAST_NOTIFICATIONS_UPDATE_CLEARED); + filter.addAction(BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED); + BroadcastReceiver receiverNotificationsCleared = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case BROADCAST_NOTIFICATIONS_ALL_UPDATES_CLEARED: + appUpdateStatusManager.clearAllUpdates(); + break; + case BROADCAST_NOTIFICATIONS_ALL_INSTALLED_CLEARED: + appUpdateStatusManager.clearAllInstalled(); + break; + case BROADCAST_NOTIFICATIONS_UPDATE_CLEARED: + // If clearing apps in state "InstallError" (like when auto-cancelling) we + // remove them from the status manager entirely. + AppUpdateStatusManager.AppUpdateStatus appUpdateStatus = appUpdateStatusManager.get(intent.getStringExtra(EXTRA_NOTIFICATION_KEY)); + if (appUpdateStatus != null && appUpdateStatus.status == AppUpdateStatusManager.Status.InstallError) { + appUpdateStatusManager.removeApk(intent.getStringExtra(EXTRA_NOTIFICATION_KEY)); + } + break; + case BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED: + appUpdateStatusManager.removeApk(intent.getStringExtra(EXTRA_NOTIFICATION_KEY)); + break; + } + } + }; + context.registerReceiver(receiverNotificationsCleared, filter); + filter = new IntentFilter(); + filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED); + filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED); + filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_CHANGED); + filter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED); + BroadcastReceiver receiverAppStatusChanges = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + AppUpdateStatusManager.AppUpdateStatus entry; + String url; + + switch (intent.getAction()) { + case AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED: + notificationManager.cancelAll(); + updateStatusLists(); + createSummaryNotifications(); + for (AppUpdateStatusManager.AppUpdateStatus appUpdateStatus : appUpdateStatusManager.getAll()) { + createNotification(appUpdateStatus); + } + break; + case AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED: + updateStatusLists(); + createSummaryNotifications(); + url = intent.getStringExtra(AppUpdateStatusManager.EXTRA_APK_URL); + entry = appUpdateStatusManager.get(url); + if (entry != null) { + createNotification(entry); + } + break; + case AppUpdateStatusManager.BROADCAST_APPSTATUS_CHANGED: + url = intent.getStringExtra(AppUpdateStatusManager.EXTRA_APK_URL); + entry = appUpdateStatusManager.get(url); + updateStatusLists(); + if (entry != null) { + createNotification(entry); + } + if (intent.getBooleanExtra(AppUpdateStatusManager.EXTRA_IS_STATUS_UPDATE, false)) { + createSummaryNotifications(); + } + break; + case AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED: + url = intent.getStringExtra(AppUpdateStatusManager.EXTRA_APK_URL); + notificationManager.cancel(url, NOTIFY_ID_INSTALLED); + notificationManager.cancel(url, NOTIFY_ID_UPDATES); + updateStatusLists(); + createSummaryNotifications(); + break; + } + } + }; + LocalBroadcastManager.getInstance(context).registerReceiver(receiverAppStatusChanges, filter); + } + + private boolean useStackedNotifications() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + + /** + * Populate {@link NotificationHelper#updates} and {@link NotificationHelper#installed} with + * the relevant status entries from the {@link AppUpdateStatusManager}. + */ + private void updateStatusLists() { + if (!notificationManager.areNotificationsEnabled()) { + return; + } + + updates.clear(); + installed.clear(); + + for (AppUpdateStatusManager.AppUpdateStatus entry : appUpdateStatusManager.getAll()) { + if (entry.status == AppUpdateStatusManager.Status.Installed) { + installed.add(entry); + } else if (!shouldIgnoreEntry(entry)) { + updates.add(entry); + } + } + } + + private boolean shouldIgnoreEntry(AppUpdateStatusManager.AppUpdateStatus entry) { + // Ignore unknown status + if (entry.status == AppUpdateStatusManager.Status.Unknown) { + return true; + } else if ((entry.status == AppUpdateStatusManager.Status.Downloading || entry.status == AppUpdateStatusManager.Status.ReadyToInstall || entry.status == AppUpdateStatusManager.Status.InstallError) && + (AppDetails.isAppVisible(entry.app.packageName) || AppDetails2.isAppVisible(entry.app.packageName))) { + // Ignore downloading, readyToInstall and installError if we are showing the details screen for this app + return true; + } + return false; + } + + private void createNotification(AppUpdateStatusManager.AppUpdateStatus entry) { + if (shouldIgnoreEntry(entry)) { + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_UPDATES); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_INSTALLED); + return; + } + + if (!notificationManager.areNotificationsEnabled()) { + return; + } + + Notification notification; + if (entry.status == AppUpdateStatusManager.Status.Installed) { + if (useStackedNotifications()) { + notification = createInstalledNotification(entry); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_UPDATES); + notificationManager.notify(entry.getUniqueKey(), NOTIFY_ID_INSTALLED, notification); + } else if (installed.size() == 1) { + notification = createInstalledNotification(entry); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_UPDATES); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_INSTALLED); + notificationManager.notify(GROUP_INSTALLED, NOTIFY_ID_INSTALLED, notification); + } + } else { + if (useStackedNotifications()) { + notification = createUpdateNotification(entry); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_INSTALLED); + notificationManager.notify(entry.getUniqueKey(), NOTIFY_ID_UPDATES, notification); + } else if (updates.size() == 1) { + notification = createUpdateNotification(entry); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_UPDATES); + notificationManager.cancel(entry.getUniqueKey(), NOTIFY_ID_INSTALLED); + notificationManager.notify(GROUP_UPDATES, NOTIFY_ID_UPDATES, notification); + } + } + } + + private void createSummaryNotifications() { + if (!notificationManager.areNotificationsEnabled()) { + return; + } + + Notification notification; + if (updates.size() != 1 || useStackedNotifications()) { + if (updates.size() == 0) { + // No updates, remove summary + notificationManager.cancel(GROUP_UPDATES, NOTIFY_ID_UPDATES); + } else { + notification = createUpdateSummaryNotification(updates); + notificationManager.notify(GROUP_UPDATES, NOTIFY_ID_UPDATES, notification); + } + } + if (installed.size() != 1 || useStackedNotifications()) { + if (installed.size() == 0) { + // No installed, remove summary + notificationManager.cancel(GROUP_INSTALLED, NOTIFY_ID_INSTALLED); + } else { + notification = createInstalledSummaryNotification(installed); + notificationManager.notify(GROUP_INSTALLED, NOTIFY_ID_INSTALLED, notification); + } + } + } + + private NotificationCompat.Action getAction(AppUpdateStatusManager.AppUpdateStatus entry) { + if (entry.intent != null) { + switch (entry.status) { + case UpdateAvailable: + return new NotificationCompat.Action(R.drawable.ic_file_download, context.getString(R.string.notification_action_update), entry.intent); + + case Downloading: + case Installing: + return new NotificationCompat.Action(R.drawable.ic_cancel, context.getString(R.string.notification_action_cancel), entry.intent); + + case ReadyToInstall: + return new NotificationCompat.Action(R.drawable.ic_file_install, context.getString(R.string.notification_action_install), entry.intent); + } + } + return null; + } + + private String getSingleItemTitleString(App app, AppUpdateStatusManager.Status status) { + switch (status) { + case UpdateAvailable: + return context.getString(R.string.notification_title_single_update_available); + case Downloading: + return app.name; + case ReadyToInstall: + return context.getString(app.isInstalled() ? R.string.notification_title_single_ready_to_install_update : R.string.notification_title_single_ready_to_install); + case Installing: + return app.name; + case Installed: + return app.name; + case InstallError: + return context.getString(R.string.notification_title_single_install_error); + } + return ""; + } + + private String getSingleItemContentString(App app, AppUpdateStatusManager.Status status) { + switch (status) { + case UpdateAvailable: + return app.name; + case Downloading: + return context.getString(app.isInstalled() ? R.string.notification_content_single_downloading_update : R.string.notification_content_single_downloading, app.name); + case ReadyToInstall: + return app.name; + case Installing: + return context.getString(R.string.notification_content_single_installing, app.name); + case Installed: + return context.getString(R.string.notification_content_single_installed); + case InstallError: + return app.name; + } + return ""; + } + + private String getMultiItemContentString(App app, AppUpdateStatusManager.Status status) { + switch (status) { + case UpdateAvailable: + return context.getString(R.string.notification_title_summary_update_available); + case Downloading: + return context.getString(app.isInstalled() ? R.string.notification_title_summary_downloading_update : R.string.notification_title_summary_downloading); + case ReadyToInstall: + return context.getString(app.isInstalled() ? R.string.notification_title_summary_ready_to_install_update : R.string.notification_title_summary_ready_to_install); + case Installing: + return context.getString(R.string.notification_title_summary_installing); + case Installed: + return context.getString(R.string.notification_title_summary_installed); + case InstallError: + return context.getString(R.string.notification_title_summary_install_error); + } + return ""; + } + + private Notification createUpdateNotification(AppUpdateStatusManager.AppUpdateStatus entry) { + App app = entry.app; + AppUpdateStatusManager.Status status = entry.status; + + int iconSmall = R.drawable.ic_launcher; + Bitmap iconLarge = getLargeIconForEntry(entry); + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setContentTitle(getSingleItemTitleString(app, status)) + .setContentText(getSingleItemContentString(app, status)) + .setSmallIcon(iconSmall) + .setColor(ContextCompat.getColor(context, R.color.fdroid_blue)) + .setLargeIcon(iconLarge) + .setLocalOnly(true) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setContentIntent(entry.intent); + + /* If using stacked notifications, use groups. Note that this would not work prior to Lollipop, + because of http://stackoverflow.com/a/34953411, but currently not an issue since stacked + notifications are used only on >= Nougat. + */ + if (useStackedNotifications()) { + builder.setGroup(GROUP_UPDATES); + } + + // Handle actions + // + NotificationCompat.Action action = getAction(entry); + if (action != null) { + builder.addAction(action); + } + + // Handle progress bar (for some states) + // + if (status == AppUpdateStatusManager.Status.Downloading) { + if (entry.progressMax == 0) { + builder.setProgress(100, 0, true); + } else { + builder.setProgress(entry.progressMax, entry.progressCurrent, false); + } + } else if (status == AppUpdateStatusManager.Status.Installing) { + builder.setProgress(100, 0, true); // indeterminate bar + } + + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_UPDATE_CLEARED); + intentDeleted.putExtra(EXTRA_NOTIFICATION_KEY, entry.getUniqueKey()); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder.build(); + } + + private Notification createUpdateSummaryNotification(ArrayList updates) { + String title = context.getString(R.string.notification_summary_updates, updates.size()); + StringBuilder text = new StringBuilder(); + + NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + inboxStyle.setBigContentTitle(title); + + for (int i = 0; i < MAX_UPDATES_TO_SHOW && i < updates.size(); i++) { + AppUpdateStatusManager.AppUpdateStatus entry = updates.get(i); + App app = entry.app; + AppUpdateStatusManager.Status status = entry.status; + + String content = getMultiItemContentString(app, status); + SpannableStringBuilder sb = new SpannableStringBuilder(app.name); + sb.setSpan(new StyleSpan(Typeface.BOLD), 0, sb.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + sb.append(" "); + sb.append(content); + inboxStyle.addLine(sb); + + if (text.length() > 0) { + text.append(", "); + } + text.append(app.name); + } + + if (updates.size() > MAX_UPDATES_TO_SHOW) { + int diff = updates.size() - MAX_UPDATES_TO_SHOW; + inboxStyle.setSummaryText(context.getString(R.string.notification_summary_more, diff)); + } + + // Intent to open main app list + Intent intentObject = new Intent(context, FDroid.class); + PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(!useStackedNotifications()) + .setSmallIcon(R.drawable.ic_launcher) + .setColor(ContextCompat.getColor(context, R.color.fdroid_blue)) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(piAction) + .setLocalOnly(true) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setStyle(inboxStyle); + + if (useStackedNotifications()) { + builder.setGroup(GROUP_UPDATES) + .setGroupSummary(true); + } + + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_ALL_UPDATES_CLEARED); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder.build(); + } + + private Notification createInstalledNotification(AppUpdateStatusManager.AppUpdateStatus entry) { + App app = entry.app; + + Bitmap iconLarge = getLargeIconForEntry(entry); + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(true) + .setLargeIcon(iconLarge) + .setSmallIcon(R.drawable.ic_launcher) + .setColor(ContextCompat.getColor(context, R.color.fdroid_blue)) + .setContentTitle(app.name) + .setContentText(context.getString(R.string.notification_content_single_installed)) + .setLocalOnly(true) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setContentIntent(entry.intent); + + if (useStackedNotifications()) { + builder.setGroup(GROUP_INSTALLED); + } + + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED); + intentDeleted.putExtra(EXTRA_NOTIFICATION_KEY, entry.getUniqueKey()); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder.build(); + } + + private Notification createInstalledSummaryNotification(ArrayList installed) { + String title = context.getString(R.string.notification_summary_installed, installed.size()); + StringBuilder text = new StringBuilder(); + + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle(); + bigTextStyle.setBigContentTitle(title); + + for (int i = 0; i < MAX_INSTALLED_TO_SHOW && i < installed.size(); i++) { + AppUpdateStatusManager.AppUpdateStatus entry = installed.get(i); + App app = entry.app; + if (text.length() > 0) { + text.append(", "); + } + text.append(app.name); + } + bigTextStyle.bigText(text); + if (installed.size() > MAX_INSTALLED_TO_SHOW) { + int diff = installed.size() - MAX_INSTALLED_TO_SHOW; + bigTextStyle.setSummaryText(context.getString(R.string.notification_summary_more, diff)); + } + + // Intent to open main app list + Intent intentObject = new Intent(context, FDroid.class); + PendingIntent piAction = PendingIntent.getActivity(context, 0, intentObject, 0); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context) + .setAutoCancel(!useStackedNotifications()) + .setSmallIcon(R.drawable.ic_launcher) + .setColor(ContextCompat.getColor(context, R.color.fdroid_blue)) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(piAction) + .setLocalOnly(true) + .setVisibility(NotificationCompat.VISIBILITY_SECRET); + if (useStackedNotifications()) { + builder.setGroup(GROUP_INSTALLED) + .setGroupSummary(true); + } + Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_ALL_INSTALLED_CLEARED); + PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, 0); + builder.setDeleteIntent(piDeleted); + return builder.build(); + } + + private Point getLargeIconSize() { + int w; + int h; + if (Build.VERSION.SDK_INT >= 11) { + w = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + h = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + } else { + w = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); + h = w; + } + return new Point(w, h); + } + + private Bitmap getLargeIconForEntry(AppUpdateStatusManager.AppUpdateStatus entry) { + final Point largeIconSize = getLargeIconSize(); + Bitmap iconLarge = null; + if (entry.status == AppUpdateStatusManager.Status.Downloading || entry.status == AppUpdateStatusManager.Status.Installing) { + Bitmap bitmap = Bitmap.createBitmap(largeIconSize.x, largeIconSize.y, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + Drawable downloadIcon = VectorDrawableCompat.create(context.getResources(), R.drawable.ic_notification_download, context.getTheme()); + if (downloadIcon != null) { + downloadIcon.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + downloadIcon.draw(canvas); + } + return bitmap; + } else if (DiskCacheUtils.findInCache(entry.app.iconUrl, ImageLoader.getInstance().getDiskCache()) != null) { + iconLarge = ImageLoader.getInstance().loadImageSync(entry.app.iconUrl, new ImageSize(largeIconSize.x, largeIconSize.y), displayImageOptions); + } else { + // Load it for later! + ImageLoader.getInstance().loadImage(entry.app.iconUrl, new ImageSize(largeIconSize.x, largeIconSize.y), displayImageOptions, new ImageLoadingListener() { + + AppUpdateStatusManager.AppUpdateStatus entry; + + ImageLoadingListener init(AppUpdateStatusManager.AppUpdateStatus entry) { + this.entry = entry; + return this; + } + + @Override + public void onLoadingStarted(String imageUri, View view) { + + } + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { + + } + + @Override + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + // Need to check that the notification is still valid, and also that the image + // is indeed cached now, so we won't get stuck in an endless loop. + AppUpdateStatusManager.AppUpdateStatus oldEntry = appUpdateStatusManager.get(entry.getUniqueKey()); + if (oldEntry != null && DiskCacheUtils.findInCache(oldEntry.app.iconUrl, ImageLoader.getInstance().getDiskCache()) != null) { + createNotification(oldEntry); // Update with new image! + } + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + + } + }.init(entry)); + } + return iconLarge; + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index fbf780caa..58b3ce6d4 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -37,7 +37,6 @@ import android.os.Process; import android.os.SystemClock; import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; -import android.support.v4.app.TaskStackBuilder; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; @@ -79,7 +78,6 @@ public class UpdateService extends IntentService { private static final String STATE_LAST_UPDATED = "lastUpdateCheck"; private static final int NOTIFY_ID_UPDATING = 0; - private static final int NOTIFY_ID_UPDATES_AVAILABLE = 1; private static final int FLAG_NET_UNAVAILABLE = 0; private static final int FLAG_NET_METERED = 1; @@ -89,6 +87,7 @@ public class UpdateService extends IntentService { private NotificationManager notificationManager; private NotificationCompat.Builder notificationBuilder; + private AppUpdateStatusManager appUpdateStatusManager; public UpdateService() { super("UpdateService"); @@ -147,6 +146,7 @@ public class UpdateService extends IntentService { .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentTitle(getString(R.string.update_notification_title)); + appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); // Android docs are a little sketchy, however it seems that Gingerbread is the last // sdk that made a content intent mandatory: @@ -469,39 +469,6 @@ public class UpdateService extends IntentService { } } - private PendingIntent createNotificationIntent() { - Intent notifyIntent = new Intent(this, FDroid.class).putExtra(FDroid.EXTRA_TAB_UPDATE, true); - TaskStackBuilder stackBuilder = TaskStackBuilder - .create(this).addParentStack(FDroid.class) - .addNextIntent(notifyIntent); - return stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); - } - - private static final int MAX_UPDATES_TO_SHOW = 5; - - private NotificationCompat.Style createNotificationBigStyle(Cursor hasUpdates) { - - final String contentText = hasUpdates.getCount() > 1 - ? getString(R.string.many_updates_available, hasUpdates.getCount()) - : getString(R.string.one_update_available); - - NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); - inboxStyle.setBigContentTitle(contentText); - hasUpdates.moveToFirst(); - for (int i = 0; i < Math.min(hasUpdates.getCount(), MAX_UPDATES_TO_SHOW); i++) { - App app = new App(hasUpdates); - hasUpdates.moveToNext(); - inboxStyle.addLine(app.name + " (" + app.installedVersionName + " → " + app.getSuggestedVersionName() + ")"); - } - - if (hasUpdates.getCount() > MAX_UPDATES_TO_SHOW) { - int diff = hasUpdates.getCount() - MAX_UPDATES_TO_SHOW; - inboxStyle.setSummaryText(getString(R.string.update_notification_more, diff)); - } - - return inboxStyle; - } - private void autoDownloadUpdates() { Cursor cursor = getContentResolver().query( AppProvider.getCanUpdateUri(), @@ -520,24 +487,16 @@ public class UpdateService extends IntentService { } private void showAppUpdatesNotification(Cursor hasUpdates) { - Utils.debugLog(TAG, "Notifying " + hasUpdates.getCount() + " updates."); - - final int icon = Build.VERSION.SDK_INT >= 11 ? R.drawable.ic_stat_notify_updates : R.drawable.ic_launcher; - - final String contentText = hasUpdates.getCount() > 1 - ? getString(R.string.many_updates_available, hasUpdates.getCount()) - : getString(R.string.one_update_available); - - NotificationCompat.Builder builder = - new NotificationCompat.Builder(this) - .setAutoCancel(true) - .setContentTitle(getString(R.string.fdroid_updates_available)) - .setSmallIcon(icon) - .setContentIntent(createNotificationIntent()) - .setContentText(contentText) - .setStyle(createNotificationBigStyle(hasUpdates)); - - notificationManager.notify(NOTIFY_ID_UPDATES_AVAILABLE, builder.build()); + if (hasUpdates != null) { + hasUpdates.moveToFirst(); + List apksToUpdate = new ArrayList<>(hasUpdates.getCount()); + for (int i = 0; i < hasUpdates.getCount(); i++) { + App app = new App(hasUpdates); + hasUpdates.moveToNext(); + apksToUpdate.add(ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode)); + } + appUpdateStatusManager.addApks(apksToUpdate, AppUpdateStatusManager.Status.UpdateAvailable); + } } /** diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java index 226aafd6e..51cc09f3d 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -1,43 +1,31 @@ package org.fdroid.fdroid.installer; import android.app.Notification; -import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.PackageManager; import android.net.Uri; import android.os.IBinder; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.TaskStackBuilder; -import android.support.v4.content.IntentCompat; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import org.apache.commons.io.FileUtils; import org.apache.commons.io.filefilter.WildcardFileFilter; -import org.fdroid.fdroid.AppDetails; +import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.Hasher; -import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.PackageManagerCompat; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.AppProvider; -import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.DownloaderService; import java.io.File; import java.io.FileFilter; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; /** * Manages the whole process when a background update triggers an install or the user @@ -83,22 +71,8 @@ public class InstallManagerService extends Service { private static final String EXTRA_APP = "org.fdroid.fdroid.installer.extra.APP"; private static final String EXTRA_APK = "org.fdroid.fdroid.installer.extra.APK"; - /** - * The collection of {@link Apk}s that are actively going through this whole process, - * matching the {@link App}s in {@code ACTIVE_APPS}. The key is the download URL, as - * in {@link Apk#getUrl()} or {@code urlString}. - */ - private static final HashMap ACTIVE_APKS = new HashMap<>(3); - - /** - * The collection of {@link App}s that are actively going through this whole process, - * matching the {@link Apk}s in {@code ACTIVE_APKS}. The key is the - * {@code packageName} of the app. - */ - private static final HashMap ACTIVE_APPS = new HashMap<>(3); - private LocalBroadcastManager localBroadcastManager; - private NotificationManager notificationManager; + private AppUpdateStatusManager appUpdateStatusManager; /** * This service does not use binding, so no need to implement this method @@ -113,18 +87,14 @@ public class InstallManagerService extends Service { super.onCreate(); Utils.debugLog(TAG, "creating Service"); localBroadcastManager = LocalBroadcastManager.getInstance(this); - notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); BroadcastReceiver br = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String packageName = intent.getData().getSchemeSpecificPart(); - for (Map.Entry entry : ACTIVE_APKS.entrySet()) { - if (TextUtils.equals(packageName, entry.getValue().packageName)) { - String urlString = entry.getKey(); - cancelNotification(urlString); - break; - } + for (AppUpdateStatusManager.AppUpdateStatus status : appUpdateStatusManager.getByPackageName(packageName)) { + appUpdateStatusManager.updateApk(status.getUniqueKey(), AppUpdateStatusManager.Status.Installed, null); } } }; @@ -147,10 +117,12 @@ public class InstallManagerService extends Service { String action = intent.getAction(); if (ACTION_CANCEL.equals(action)) { DownloaderService.cancel(this, urlString); - Apk apk = getApkFromActive(urlString); - DownloaderService.cancel(this, apk.getPatchObbUrl()); - DownloaderService.cancel(this, apk.getMainObbUrl()); - cancelNotification(urlString); + Apk apk = appUpdateStatusManager.getApk(urlString); + if (apk != null) { + DownloaderService.cancel(this, apk.getPatchObbUrl()); + DownloaderService.cancel(this, apk.getMainObbUrl()); + } + appUpdateStatusManager.removeApk(urlString); return START_NOT_STICKY; } else if (!ACTION_INSTALL.equals(action)) { Utils.debugLog(TAG, "Ignoring " + intent + " as it is not an " + ACTION_INSTALL + " intent"); @@ -166,7 +138,7 @@ public class InstallManagerService extends Service { && !DownloaderService.isQueuedOrActive(urlString)) { // TODO is there a case where we should allow an active urlString to pass through? Utils.debugLog(TAG, urlString + " finished downloading while InstallManagerService was killed."); - cancelNotification(urlString); + appUpdateStatusManager.removeApk(urlString); return START_NOT_STICKY; } @@ -176,14 +148,11 @@ public class InstallManagerService extends Service { Utils.debugLog(TAG, "Intent had null EXTRA_APP and/or EXTRA_APK: " + intent); return START_NOT_STICKY; } - addToActive(urlString, app, apk); + appUpdateStatusManager.addApk(apk, AppUpdateStatusManager.Status.Unknown, null); - NotificationCompat.Builder builder = createNotificationBuilder(urlString, apk); - notificationManager.notify(urlString.hashCode(), builder.build()); - - registerApkDownloaderReceivers(urlString, builder); - getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256, builder); - getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256, builder); + registerApkDownloaderReceivers(urlString); + getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256); + getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256); File apkFilePath = ApkCache.getApkDownloadPath(this, intent.getData()); long apkFileSize = apkFilePath.length(); @@ -217,8 +186,7 @@ public class InstallManagerService extends Service { * @see APK Expansion Files */ private void getObb(final String urlString, String obbUrlString, - final File obbDestFile, final String sha256, - final NotificationCompat.Builder builder) { + final File obbDestFile, final String sha256) { if (obbDestFile == null || obbDestFile.exists() || TextUtils.isEmpty(obbUrlString)) { return; } @@ -232,8 +200,7 @@ public class InstallManagerService extends Service { int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); - builder.setProgress(totalBytes, bytesRead, false); - notificationManager.notify(urlString.hashCode(), builder.build()); + appUpdateStatusManager.updateApkProgress(urlString, totalBytes, bytesRead); } else if (Downloader.ACTION_COMPLETE.equals(action)) { localBroadcastManager.unregisterReceiver(this); File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); @@ -274,7 +241,7 @@ public class InstallManagerService extends Service { DownloaderService.getIntentFilter(obbUrlString)); } - private void registerApkDownloaderReceivers(String urlString, final NotificationCompat.Builder builder) { + private void registerApkDownloaderReceivers(String urlString) { BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override @@ -284,31 +251,36 @@ public class InstallManagerService extends Service { switch (intent.getAction()) { case Downloader.ACTION_STARTED: - // nothing to do + // App should currently be in the "Unknown" state, so this changes it to "Downloading". + Intent intentObject = new Intent(context, InstallManagerService.class); + intentObject.setAction(ACTION_CANCEL); + intentObject.setData(downloadUri); + PendingIntent action = PendingIntent.getService(context, 0, intentObject, 0); + appUpdateStatusManager.updateApk(urlString, AppUpdateStatusManager.Status.Downloading, action); break; case Downloader.ACTION_PROGRESS: int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); - builder.setProgress(totalBytes, bytesRead, false); - notificationManager.notify(urlString.hashCode(), builder.build()); + appUpdateStatusManager.updateApkProgress(urlString, totalBytes, bytesRead); break; case Downloader.ACTION_COMPLETE: File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); Uri localApkUri = Uri.fromFile(localFile); Utils.debugLog(TAG, "download completed of " + urlString + " to " + localApkUri); + appUpdateStatusManager.updateApk(urlString, AppUpdateStatusManager.Status.ReadyToInstall, null); localBroadcastManager.unregisterReceiver(this); registerInstallerReceivers(downloadUri); - Apk apk = ACTIVE_APKS.get(urlString); - - InstallerService.install(context, localApkUri, downloadUri, apk); + Apk apk = appUpdateStatusManager.getApk(urlString); + if (apk != null) { + InstallerService.install(context, localApkUri, downloadUri, apk); + } break; case Downloader.ACTION_INTERRUPTED: - removeFromActive(urlString); + appUpdateStatusManager.updateApk(urlString, AppUpdateStatusManager.Status.Unknown, null); localBroadcastManager.unregisterReceiver(this); - cancelNotification(urlString); break; default: throw new RuntimeException("intent action not handled!"); @@ -329,49 +301,31 @@ public class InstallManagerService extends Service { Apk apk; switch (intent.getAction()) { case Installer.ACTION_INSTALL_STARTED: - // nothing to do + appUpdateStatusManager.updateApk(downloadUrl, AppUpdateStatusManager.Status.Installing, null); break; case Installer.ACTION_INSTALL_COMPLETE: - Apk apkComplete = removeFromActive(downloadUrl); - - PackageManagerCompat.setInstaller(getPackageManager(), apkComplete.packageName); - + appUpdateStatusManager.updateApk(downloadUrl, AppUpdateStatusManager.Status.Installed, null); + Apk apkComplete = appUpdateStatusManager.getApk(downloadUrl); + if (apkComplete != null) { + PackageManagerCompat.setInstaller(getPackageManager(), apkComplete.packageName); + } localBroadcastManager.unregisterReceiver(this); break; case Installer.ACTION_INSTALL_INTERRUPTED: apk = intent.getParcelableExtra(Installer.EXTRA_APK); String errorMessage = intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); - - // show notification if app details is not visible if (!TextUtils.isEmpty(errorMessage)) { - App app = getAppFromActive(downloadUrl); - if (app == null) { - ContentResolver resolver = context.getContentResolver(); - app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo); - } - // show notification if app details is not visible - if (app != null && AppDetails.isAppVisible(app.packageName)) { - cancelNotification(downloadUrl); - } else { - notifyError(downloadUrl, app, errorMessage); - } + appUpdateStatusManager.setApkError(apk, errorMessage); + } else { + appUpdateStatusManager.removeApk(downloadUrl); } - removeFromActive(downloadUrl); localBroadcastManager.unregisterReceiver(this); break; case Installer.ACTION_INSTALL_USER_INTERACTION: apk = intent.getParcelableExtra(Installer.EXTRA_APK); - PendingIntent installPendingIntent = - intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); - - // show notification if app details is not visible - if (AppDetails.isAppVisible(apk.packageName)) { - cancelNotification(downloadUrl); - } else { - notifyDownloadComplete(apk, downloadUrl, installPendingIntent); - } - + PendingIntent installPendingIntent = intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); + appUpdateStatusManager.addApk(apk, AppUpdateStatusManager.Status.ReadyToInstall, installPendingIntent); break; default: throw new RuntimeException("intent action not handled!"); @@ -383,178 +337,6 @@ public class InstallManagerService extends Service { Installer.getInstallIntentFilter(downloadUri)); } - private NotificationCompat.Builder createNotificationBuilder(String urlString, Apk apk) { - int downloadUrlId = urlString.hashCode(); - return new NotificationCompat.Builder(this) - .setAutoCancel(false) - .setOngoing(true) - .setContentIntent(getAppDetailsIntent(downloadUrlId, apk)) - .setContentTitle(getString(R.string.downloading_apk, getAppName(apk))) - .addAction(R.drawable.ic_cancel_black_24dp, getString(R.string.cancel), - getCancelPendingIntent(urlString)) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentText(urlString) - .setProgress(100, 0, true); - } - - private String getAppName(Apk apk) { - return ACTIVE_APPS.get(apk.packageName).name; - } - - /** - * Get a {@link PendingIntent} for a {@link Notification} to send when it - * is clicked. {@link AppDetails} handles {@code Intent}s that are missing - * or bad {@link AppDetails#EXTRA_APPID}, so it does not need to be checked - * here. - */ - private PendingIntent getAppDetailsIntent(int requestCode, Apk apk) { - Intent notifyIntent = new Intent(getApplicationContext(), AppDetails.class) - .putExtra(AppDetails.EXTRA_APPID, apk.packageName); - return TaskStackBuilder.create(getApplicationContext()) - .addParentStack(AppDetails.class) - .addNextIntent(notifyIntent) - .getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT); - } - - /** - * Post a notification about a completed download. {@code packageName} must be a valid - * and currently in the app index database. This must create a new {@code Builder} - * instance otherwise the progress/cancel stuff does not go away. - * - * @see Issue 47809: - * Removing the progress bar from a notification should cause the notification's content - * text to return to normal size - */ - private void notifyDownloadComplete(Apk apk, String urlString, PendingIntent installPendingIntent) { - String title; - try { - PackageManager pm = getPackageManager(); - title = String.format(getString(R.string.tap_to_update_format), - pm.getApplicationLabel(pm.getApplicationInfo(apk.packageName, 0))); - } catch (PackageManager.NameNotFoundException e) { - String name = getAppName(apk); - if (TextUtils.isEmpty(name) || name.equals(new App().name)) { - ContentResolver resolver = getContentResolver(); - App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo, - new String[]{Schema.AppMetadataTable.Cols.NAME}); - if (app == null || TextUtils.isEmpty(app.name)) { - return; // do not have a name to display, so leave notification as is - } - name = app.name; - } - title = String.format(getString(R.string.tap_to_install_format), name); - } - - int downloadUrlId = urlString.hashCode(); - notificationManager.cancel(downloadUrlId); - Notification notification = new NotificationCompat.Builder(this) - .setAutoCancel(true) - .setOngoing(false) - .setContentTitle(title) - .setContentIntent(installPendingIntent) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentText(getString(R.string.tap_to_install)) - .build(); - notificationManager.notify(downloadUrlId, notification); - } - - private void notifyError(String urlString, App app, String text) { - int downloadUrlId = urlString.hashCode(); - - String name; - if (app == null) { - // if we have nothing else, show the APK filename - String path = Uri.parse(urlString).getPath(); - name = path.substring(path.lastIndexOf('/'), path.length()); - } else { - name = app.name; - } - String title = String.format(getString(R.string.install_error_notify_title), name); - - Intent errorDialogIntent = new Intent(this, ErrorDialogActivity.class); - errorDialogIntent.putExtra( - ErrorDialogActivity.EXTRA_TITLE, title); - errorDialogIntent.putExtra( - ErrorDialogActivity.EXTRA_MESSAGE, text); - PendingIntent errorDialogPendingIntent = PendingIntent.getActivity( - getApplicationContext(), - downloadUrlId, - errorDialogIntent, - PendingIntent.FLAG_UPDATE_CURRENT); - - NotificationCompat.Builder builder = - new NotificationCompat.Builder(this) - .setAutoCancel(true) - .setContentTitle(title) - .setContentIntent(errorDialogPendingIntent) - .setSmallIcon(R.drawable.ic_issues) - .setContentText(text); - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - nm.notify(downloadUrlId, builder.build()); - } - - /** - * Cancel the {@link Notification} tied to {@code urlString}, which is the - * unique ID used to represent a given APK file. {@link String#hashCode()} - * converts {@code urlString} to the required {@code int}. - */ - private void cancelNotification(String urlString) { - notificationManager.cancel(urlString.hashCode()); - } - - private static void addToActive(String urlString, App app, Apk apk) { - ACTIVE_APKS.put(urlString, apk); - ACTIVE_APPS.put(app.packageName, app); - } - - /** - * Always returns an {@link Apk} instance to avoid annoying null guards. - */ - private static Apk getApkFromActive(String urlString) { - Apk apk = ACTIVE_APKS.get(urlString); - if (apk == null) { - return new Apk(); - } else { - return apk; - } - } - - /** - * Remove the {@link App} and {@Apk} instances that are associated with - * {@code urlString} from the {@link Map} of active apps. This can be - * called after this service has been destroyed and recreated based on the - * {@link BroadcastReceiver}s, in which case {@code urlString} would not - * find anything in the active maps. - */ - private static App getAppFromActive(String urlString) { - return ACTIVE_APPS.get(getApkFromActive(urlString).packageName); - } - - /** - * Remove the URL from this service, and return the {@link Apk}. This returns - * an empty {@code Apk} instance if we get a null one so the code doesn't need - * lots of null guards. - */ - private static Apk removeFromActive(String urlString) { - Apk apk = ACTIVE_APKS.remove(urlString); - if (apk == null) { - return new Apk(); - } - ACTIVE_APPS.remove(apk.packageName); - return apk; - } - - private PendingIntent getCancelPendingIntent(String urlString) { - Intent intent = new Intent(this, InstallManagerService.class) - .setData(Uri.parse(urlString)) - .setAction(ACTION_CANCEL) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK); - return PendingIntent.getService(this, - urlString.hashCode(), - intent, - PendingIntent.FLAG_UPDATE_CURRENT); - } - /** * Install an APK, checking the cache and downloading if necessary before starting the process. * All notifications are sent as an {@link Intent} via local broadcasts to be received by @@ -581,23 +363,4 @@ public class InstallManagerService extends Service { intent.setData(Uri.parse(urlString)); context.startService(intent); } - - /** - * Returns a {@link Set} of the {@code urlString}s that are currently active. - * {@code urlString}s are used as unique IDs throughout the - * {@code InstallManagerService} process, either as a {@code String} or as an - * {@code int} from {@link String#hashCode()}. - */ - public static Set getActiveDownloadUrls() { - return ACTIVE_APKS.keySet(); - } - - /** - * Returns a {@link Set} of the {@code packageName}s that are currently active. - * {@code packageName}s are used as unique IDs for apps throughout all of - * Android, F-Droid, and other apps stores. - */ - public static Set getActivePackageNames() { - return ACTIVE_APPS.keySet(); - } } diff --git a/app/src/main/res/drawable-anydpi-v21/ic_cancel.xml b/app/src/main/res/drawable-anydpi-v21/ic_cancel.xml new file mode 100644 index 000000000..784cc0b1e --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/ic_cancel.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/ic_file_download.xml b/app/src/main/res/drawable-anydpi-v21/ic_file_download.xml new file mode 100644 index 000000000..11b5def17 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/ic_file_download.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable-anydpi-v21/ic_file_install.xml b/app/src/main/res/drawable-anydpi-v21/ic_file_install.xml new file mode 100644 index 000000000..644cb6070 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/ic_file_install.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_cancel.png b/app/src/main/res/drawable-hdpi/ic_cancel.png new file mode 100644 index 000000000..e18c7aef9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_file_download.png b/app/src/main/res/drawable-hdpi/ic_file_download.png new file mode 100644 index 000000000..52fa71dc4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_file_download.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_file_install.png b/app/src/main/res/drawable-hdpi/ic_file_install.png new file mode 100644 index 000000000..81ca22780 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_file_install.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_cancel.png b/app/src/main/res/drawable-mdpi/ic_cancel.png new file mode 100644 index 000000000..418410737 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_file_download.png b/app/src/main/res/drawable-mdpi/ic_file_download.png new file mode 100644 index 000000000..7c65d4c76 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_file_download.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_file_install.png b/app/src/main/res/drawable-mdpi/ic_file_install.png new file mode 100644 index 000000000..4cc23747a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_file_install.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_cancel.png b/app/src/main/res/drawable-xhdpi/ic_cancel.png new file mode 100644 index 000000000..3e85c8a97 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_file_download.png b/app/src/main/res/drawable-xhdpi/ic_file_download.png new file mode 100644 index 000000000..2923dd893 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_file_download.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_file_install.png b/app/src/main/res/drawable-xhdpi/ic_file_install.png new file mode 100644 index 000000000..1de323ce1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_file_install.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_cancel.png b/app/src/main/res/drawable-xxhdpi/ic_cancel.png new file mode 100644 index 000000000..155ddbc59 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_file_download.png b/app/src/main/res/drawable-xxhdpi/ic_file_download.png new file mode 100644 index 000000000..afe04b6d4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_file_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_file_install.png b/app/src/main/res/drawable-xxhdpi/ic_file_install.png new file mode 100644 index 000000000..c9f7923c8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_file_install.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_cancel.png b/app/src/main/res/drawable-xxxhdpi/ic_cancel.png new file mode 100644 index 000000000..67d8ea595 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_file_download.png b/app/src/main/res/drawable-xxxhdpi/ic_file_download.png new file mode 100644 index 000000000..025407ad2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_file_download.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_file_install.png b/app/src/main/res/drawable-xxxhdpi/ic_file_install.png new file mode 100644 index 000000000..c38be0310 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_file_install.png differ diff --git a/app/src/main/res/drawable/ic_notification_download.xml b/app/src/main/res/drawable/ic_notification_download.xml new file mode 100644 index 000000000..5dcc185c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_download.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/donate_bitcoin.xml b/app/src/main/res/layout/donate_bitcoin.xml index 2ba601ee6..9e538fb8e 100644 --- a/app/src/main/res/layout/donate_bitcoin.xml +++ b/app/src/main/res/layout/donate_bitcoin.xml @@ -1,11 +1,12 @@ \ No newline at end of file diff --git a/app/src/main/res/layout/donate_flattr.xml b/app/src/main/res/layout/donate_flattr.xml index e78df12c6..ad9092e77 100644 --- a/app/src/main/res/layout/donate_flattr.xml +++ b/app/src/main/res/layout/donate_flattr.xml @@ -1,11 +1,12 @@ \ No newline at end of file diff --git a/app/src/main/res/layout/donate_litecoin.xml b/app/src/main/res/layout/donate_litecoin.xml index beca15e9f..7a84eb367 100644 --- a/app/src/main/res/layout/donate_litecoin.xml +++ b/app/src/main/res/layout/donate_litecoin.xml @@ -1,7 +1,8 @@ \ No newline at end of file diff --git a/app/src/main/res/layout/share_header_item.xml b/app/src/main/res/layout/share_header_item.xml index ddf0aa942..daa2d000d 100644 --- a/app/src/main/res/layout/share_header_item.xml +++ b/app/src/main/res/layout/share_header_item.xml @@ -1,13 +1,14 @@ - + android:paddingBottom="20dp"> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d4e8e770e..b162d7bc9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -119,10 +119,8 @@ تم التثبيت تم التثبيت (%d) تحديثات (%d) - يتوفر تحديث. - %d يتوفر على تحديثات. تحديثات اف-درويد متوفرة - +%1$d المزيد… + +%1$d المزيد… لا توجد طريقة إرسال بلوتوث، فضلاً إختر واحدة ! هذه المستودعات بالفعل موجودة، بهذا سوف يتم إضافة مفتاح معلومات جديد. هذه المستودعات بالفعل موجودة، تأكد إن كنت ترغب في إعادة تمكينه. diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index 84d7f50cb..90c5be67b 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -45,8 +45,6 @@ Sobrescribir Disponible Anovamientos - Ta disponible 1 anovamientu. - Tán disponibles %d anovamientos. Anovamientos de F-Droid disponibles Nun s\'alcontró dal métodu d\'unviu pente Bluetooth, ¡escueyi ún! Escueyi\'l métodu d\'unvu pente Bluetooth @@ -214,7 +212,7 @@ rehabilitar esti repositoriu pa instalar aplicaciones dende elli. Atrás Instaláu - +%1$d más… + +%1$d más… Buelga incorreuta Esto nun ye una URL válida. Rexistru de cambeos diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 8db31a1f1..b4ebb534d 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -42,8 +42,6 @@ Добавете ключ Налични Обновявания - 1 налична актуализация. - %d налични актуализации. Налични актуализации от F-Droid Не е намерен Bluetooth метод за изпращане, изберете един! Изберете Bluetooth метод за изпращане @@ -181,7 +179,7 @@ Изходен код Инсталирани - + още %1$d… + + още %1$d… Актуализиране на хранилищата Инсталирането се провали поради неизвестна грешка Деинсталирането се провали поради неизвестна грешка diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 24eca82b3..b26d706d2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -44,8 +44,6 @@ Sobreescriu Disponible Actualitzacions - Hi ha 1 actualització disponible. - Hi ha %d actualitzacions disponibles. Hi ha actualitzacions disponibles de l\'F-Droid Trieu un mètode d\'enviament per Bluetooth! Envia per Bluetooth @@ -181,7 +179,7 @@ tornar a habilitar el dipòsit per instal·lar aplicacions d\'aquest. Enllaços Torna - +%1$d més… + +%1$d més… Idioma Wi-Fi Gràfics diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b8f9cf24e..549a1e09a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -41,8 +41,6 @@ Přepsat Dostupné Aktualizace - 1 aktualizace je dostupná. - %d aktualizací je dostupných. Dostupné aktualizace F-Droid Poslat přes bluetooth Adresa repositáře @@ -152,7 +150,7 @@ Pro instalaci aplikací z tohoto repozitáře ho bude nejprve třeba znovu povol Odkazy Zpět - +%1$d více… + +%1$d více… Nerozpoznána žádná Bluetooth metoda přenosu, nějakou vyberte! Vyberte metodu přenosu Bluetooth Tento repozitář je již nastaven, budou jen přidány nové klíče. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 93d9dc9aa..3082002c7 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -75,10 +75,8 @@ Opdateringer Installeret (%d) Opdateringer (%d) - 1 opdatering er tilgængelig. - %d opdateringer er tilgængelige. F-Droid Opdateringer Tilgængelige - +%1$d flere… + +%1$d flere… Ingen Bluetooth afsendelsesmetode fundet, vælg en! Vælg Bluetooth afsendelsesmetode Send over Bluetooth diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b5a7e4e8f..f613a9547 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -45,8 +45,6 @@ Überschreiben Verfügbar Aktualisierungen - Eine Aktualisierung ist verfügbar. - %d Aktualisierungen sind verfügbar. F-Droid: Aktualisierungen verfügbar Keine Bluetooth-Sendemethode gefunden, bitte eine auswählen! Bluetooth-Sendemethode auswählen @@ -182,7 +180,7 @@ um Anwendungen daraus installieren zu können. Inkompatibel Verweise Zurück - +%1$d weitere … + +%1$d weitere … Falscher Fingerabdruck Das ist keine gültige Adresse. Paketquellen werden aktualisiert diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index f546703ae..3d910a241 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -37,8 +37,6 @@ Αντικατάσταση Διαθέσιμα Ενημερώσεις - 1 διαθέσιμη ενημερώση. - %d διαθέσιμες ενημερώσεις. Υπάρχουν ενημερώσεις του F-Droid Καμία μέθοδος αποστολής με Bluetooth δεν βρέθηκε, επιλέξετε μία! Επιλέξτε τη μέθοδο αποστολής με Bluetooth @@ -142,7 +140,7 @@ Πίσω Εγκατεστημένο - +%1$d περισσότερα… + +%1$d περισσότερα… Αποστολή μέσω Bluetooth Λανθασμένο δακτυλικό αποτύπωμα diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 4d06f7d1f..9606e1696 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -28,8 +28,6 @@ Anstataŭigi Disponeblaj Ĝisdatigeblaj - 1 ĝisdatigo estas disponebla. - %d ĝisdatigoj estas disponeblaj. F-Droidaj ĝisdatigoj disponeblaj Sendi per Bludento Deponeja adreso @@ -151,7 +149,7 @@ Instalitaj Instalitaj (%d) Ĝisdatigoj (%d) - ankoraŭ +%1$d… + ankoraŭ +%1$d… Neniu Bludenta metodo de sendo trovita, elektu iun! Elektu Bludentan metodon de sendo Fingrospuro (malnepra) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bf26e5baf..46ceb91e3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -45,8 +45,6 @@ Sobrescribir Disponible Actualizaciones - 1 actualización disponible. - %d actualizaciones disponibles. Actualizaciones de F-Droid disponibles No se encontró método de envío Bluetooth, ¡elige uno! Elegir el método de envío Bluetooth @@ -182,7 +180,7 @@ Para ver las aplicaciones que ofrece tienes que activarlo. Enlaces Volver - +%1$d más… + +%1$d más… Actualiza/Desinstala la extensión con permisos de sistema Abre la pantalla de detalles de la extensión con permisos de sistema para actualizarla/desinstalarla Huella digital incorrecta diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index f9fbbac08..db97687ce 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -78,10 +78,8 @@ Värskendused Paigaldatud (%d) Värskendused (%d) - Üks värskendus on saadaval. - %d värskendust on saadaval. F-Droid: värskendused saadaval - +%1$d veel… + +%1$d veel… Bluetoothiga saatmisviise ei leitud. Valige üks! Vali Bluetoothiga saatmise viis Saada Bluetoothiga diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index fbe4f6ee6..a67ac036d 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -31,8 +31,6 @@ Gainidatzi Eskuragarri Eguneraketak - Eguneraketa 1 eskuragarri. - %d eguneraketa eskuragarri. F-Droid eguneraketak eskuragarri Biltegiaren helbidea Eguneratu biltegiak @@ -99,7 +97,7 @@ Loturak Atzera - +%1$d gehiago… + +%1$d gehiago… Ez da aurkitu Bluetooth bidez bidaltzeko metodorik, aukeratu bat! Aukeratu Bluetooth bidez bidaltzeko metodoa Bidali Bluetooth bidez diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 23fea4a60..97406bfaa 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -114,9 +114,7 @@ نصب شده نصب شده (%d) به‌روز رسانی‌ها (%d) - ۱ به‌روز رسانی موجود است. - %d به روز رسانی موجود است. - +%1$d بیش‌تر… + +%1$d بیش‌تر… ارسال با بلوتوث این مخزن از پیش برپا شده است. این کار، اظّلاعات کلید جدیدی را می‌افزاید. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index af32eda3f..2a092b69a 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -44,8 +44,6 @@ Korvaa Saatavilla Päivityksiä - 1 päivitys saatavilla. - %d päivitystä saatavilla. F-Droid: Päivityksiä saatavilla Bluetooth -lähetystapaa ei löytynyt, valitse yksi! Valitse Bluetooth -lähetystapa @@ -183,7 +181,7 @@ Asennettu Asennettu (%d) Päivitykset (%d) - +%1$d lisää… + +%1$d lisää… Lataa päivitykset automaattisesti Lataa päivitykset taustalla Päivitä/Poista Privileged Extension diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2947b2114..a2e8a5ebb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -45,8 +45,6 @@ Écraser Disponibles Mises à jour - Une mise à jour est disponible. - %d mises à jour sont disponibles. Des mises à jour F-Droid sont disponibles Aucune méthode d\'envoi Bluetooth n\'a été trouvée, choisissez-en une ! Choisir une méthode d\'envoi Bluetooth @@ -284,7 +282,7 @@ perdues. Elle ne requiert aucune autorisation particulière. Toutes les heures Fourni par %1$s. Ouvrir l\'écran des détails de l\'appli F-Droid Privileged Extension pour la mettre à jour/la désinstaller - %1$d de plus… + %1$d de plus… Téléchargement de \n%2$s depuis \n%1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 23933a733..e8adef054 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -27,8 +27,6 @@ Cancelar Dispoñíbel Actualizacións - 1 Actualización dispoñíbel. - %d actualizacións dispoñíbeis. Actualizacións de F-Droid dispoñíbeis Enderezo do repositorio Actualizar repositorios @@ -101,7 +99,7 @@ Sobreescribir Instalado - +%1$d máis… + +%1$d máis… Non se atopou ningún método de transmisión Bluetooth, escolla un! Escolla o método de transmisión Bluetooth Enviar por Bluetooth diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index d11df15e2..01d438602 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -54,10 +54,8 @@ זמינות עדכונים - עדכון 1 זמין. - %d עדכונים זמינים. עדכוני F-Droid זמינים - +%1$d עוד… + +%1$d עוד… לא נמצאה שיטת שליחה של Bluetooth, בחר אחת! בחר שיטת שליחה של Bluetooth שלח דרך Bluetooth diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 6ae62ef52..be431ba09 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -147,10 +147,8 @@ सारी रेपोसितोरिएस नवीनतम हैं सभी दुसरे रेपो ने कोई गलती नहीं बनाई| अपडेट के दौरान एरर: %s - १ अपडेट उपलब्ध है| - %d अपडेटस उपलब्ध हैं| F-Droid अपडेट उपलब्ध - +%1$d और… + +%1$d और… Bluettoth से भेजने का तरीका उपलब नहीं है, एक चुने! Bluetooth से भेजने का तरीका चुने Bluetooth द्वारा भेजे diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 8a87b3402..8c0061383 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -78,10 +78,8 @@ Ažuriranja Instalirano (%d) Ažuriranja (%d) - 1 ažuriranje je dostupno. - %d ažuriranja je dostupno. Ažuriranja za F-Droid dostupna - +%1$d više… + +%1$d više… Nije pronađena metoda slanja preko Bluetootha, odaberite jednu! Odaberi metodu slanja preko Bluetootha Pošalji preko Bluetootha diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a0e71c4b3..1cfffa6e3 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -35,8 +35,6 @@ Felülírás Elérhető Frissítések - 1 frissítés érhető el. - %d frissítés érhető el. F-Droid frissítés érhető el Repo cím Ujjlenyomat (opcionális) @@ -126,7 +124,7 @@ Engedélyeznie kell, hogy megtekinthesse az általa kínált appokat. Telepítve Telepítve (%d) Frissítések (%d) - +%1$d további… + +%1$d további… Küldés Bluetooth-on Rossz ujjlenyomat diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 0c422dde7..b03620d02 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -63,8 +63,6 @@ Tersedia Terpasang Pembaruan - 1 pembaruan tersedia. - %d pembaruan tersedia. Tersedia Pembaruan F-Droid Metode pengiriman Bluetooth tidak ditemukan, pilih salah satu! Pilih metode pengiriman Bluetooth @@ -166,7 +164,7 @@ Terakhir diperbarui Nama Tidak diketahui - +%1$d lainnya… + +%1$d lainnya… Pengaturan Jangan bedakan warna apl yang membutuhkan izin root Ini artinya daftar diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index ee53c696a..1117d9c81 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -66,10 +66,8 @@ Uppfærslur Uppsett (%d) Uppfærslur (%d) - 1 uppfærsla er tiltæk. - %d uppfærslur eru tiltækar. Uppfærslur á F-Droid eru tiltækar - +%1$d fleiri… + +%1$d fleiri… Engin aðferð til sendingar með Bluetooth fannst, veldu eina! Veldu aðferð til sendingar með Bluetooth Senda með Bluetooth diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6816d5f7d..ed46188be 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -43,8 +43,6 @@ Sovrascrivi Disponibile Aggiornamenti - 1 aggiornamento disponibile. - %d aggiornamenti disponibili. Aggiornamenti per F-Droid disponibili Non è possibile inviare via Bluetooth, scegliere un altro metodo! Scegli invio mediante Bluetooth @@ -333,7 +331,7 @@ non saranno rimossi. Non è richiesto alcun accesso speciale. di questa app integrata? I dati presenti non saranno rimossi. Non richiede alcun permesso speciale. Tutte - +%1$d rimanente… + +%1$d rimanente… Scambio ravvicinato Nessuna app installata corrispondente. Puoi aggiungere altre informazioni e commenti qui: diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1c99463ab..ccaa0e567 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -45,8 +45,6 @@ 上書き 入手可能 更新 - 1個の更新があります。 - %d個の更新があります. F-Droidの更新があります Bluetoothの送信方法がありません、選択してください! Bluetoothの送信方法を選択してください @@ -181,7 +179,7 @@ リンク 戻る - +%1$d 以上… + +%1$d 以上… フィンガープリントが違います 有効な URL ではありません。 変更履歴 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b6e1f9fee..116523830 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -45,8 +45,6 @@ 덮어쓰기 사용 가능 업데이트 - 1개의 업데이트를 사용할 수 있습니다. - %d개의 업데이트를 사용할 수 있습니다. F-Droid 업데이트를 사용할 수 있습니다. 블루투스 전송 방법을 찾을 수 없습니다. 선택하세요! 블루투스 전송 방법 선택 @@ -194,7 +192,7 @@ 설치됨 설치됨 (%d) 업데이트 (%d) - +%1$d 이상… + +%1$d 이상… 올바르지 않은 핑거프린트 올바른 URL이 아닙니다. 설정 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index c6368478e..db5271564 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -29,8 +29,6 @@ Perrašyti Prieinamos programos Atnaujinimai - Atsirado 1 atnaujinimas. - Galite įdiegti %d atnaujinimus. Siųsti per Bluetooth Saugyklos adresas Atnaujinti saugyklas diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index bd0e49336..eda01cbc3 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -40,8 +40,6 @@ Pārrakstīt Pieejams Atjauninājumi - 1 atjauninājums pieejams. - %d atjauninājumi pieejami. Nav Bluetooth aktivitāšu, izvēlies vienu! Izvēlies Bluesūtīšanas metodi Bluesūtīt diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml index f71147699..603041a05 100644 --- a/app/src/main/res/values-my/strings.xml +++ b/app/src/main/res/values-my/strings.xml @@ -74,10 +74,8 @@ အသစ္မြမး္မံမႈမ်ား သြင္းထားခဲ့သည္ (%d) အသစ္မြမ္းမံမႈမ်ား (%d) - အသစ္မြမ္းမံမႈ ၁ ခုရရွိႏိုင္သည္ - အသစ္မြမ္းမံမႈ %d ခုရရွိႏိုင္သည္. F-Droid အသစ္မြမ္းမံမႈရရွိႏိုင္သည္ - +%1$d ေနာက္ထပ္.. + +%1$d ေနာက္ထပ္.. ဘလူးသုဒ့္ႏွင့္ပို႔ရန္နည္းလမ္းရွာမေတြ႕ပါ။ တစ္ခုေရြးပါ! ဘလူးသုဒ့္ႏွင့္ပို႔ေသာနည္းလမ္းကိုေရြးမည္ ဘလူးသုဒ့္မွတစ္ဆင့္ပို႔မည္ diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 3b481e6eb..d15ffb55d 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -45,8 +45,6 @@ Overskriv Tilgjengelig Oppdateringer - 1 oppdatering tilgjengelig. - %d oppdateringer tilgjengelig. F-Droid: Oppdateringer tilgjengelig Ingen forsendelsesmåte for Blåtann funnet, velg en! Velg forsendelsesmåte for Blåtann @@ -185,7 +183,7 @@ skru på denne pakkebrønnen igjen for å installere programmer fra den.Tilbake Installert - +%1$d mer… + +%1$d mer… Feil i fingeravtrykk Dette er ikke en gyldig nettadresse. Endringslogg diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b3a5681a4..1d4fe6593 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -45,8 +45,6 @@ Overschrijven Beschikbaar Updates - 1 update beschikbaar. - %d updates beschikbaar. F-Droid-updates beschikbaar Geen Bluetooth-verzendmethode gevonden, kies er een! Kies Bluetooth-verzendmethode @@ -179,7 +177,7 @@ Je moet deze bron weer inschakelen indien je er apps van wil installeren.Links Terug - +%1$d meer… + +%1$d meer… Slechte vingerafdruk Dit is geen correcte URL. Lijst van veranderingen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bd646f2f5..b74969e7d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -34,7 +34,6 @@ Nadpisz Dostępne Aktualizacje - Dostępne jest 1 uaktualnienie. Uaktualnienie F-Droid jest dostępne Wyślij przez Bluetooth Adres repozytorium @@ -154,8 +153,7 @@ Uwaga: Wszystkie poprzednio zainstalowane aplikacje zostaną na urządzeniu.Linki Powrót - Liczba dostępnych aktualizacji: %d. - +%1$d więcej… + +%1$d więcej… Nie znaleziono metody do wysłania przez Bluetooth! Wybierz metodę wysłania przez Bluetooth To repozytorium jest już dodane. Klucz zostanie zaktualizowany. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f05d62636..500814b7b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -45,8 +45,6 @@ Sobrescrever Disponível Atualizações - 1 atualização disponível. - %d atualizações disponíveis. Atualizações do F-Droid disponíveis Nenhum método de envio por Bluetooth foi encontrado, escolha um! Escolher o método de envio Bluetooth @@ -186,7 +184,7 @@ reativar este repositório para instalar aplicativos a partir dele. Links Voltar - Mais +%1$d… + Mais +%1$d… Falha na fingerprint Esta não é uma URL válida. Changelog diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index df67aa098..cb98498d8 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -63,10 +63,8 @@ Disponíveis Instaladas Atualizações - 1 atualização disponível. - %d atualizações disponíveis. Atualizações F-Droid disponíveis - +%1$d… + +%1$d… Nenhum método de envio Bluetooth encontrado. Escolha um! Escolha o método de envio Bluetooth Enviar por Bluetooth diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4c2cf84d7..1d29bf79a 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -22,8 +22,6 @@ Activeaza Disponibil Actualizari - O actualizare este disponibila. - %d actualizari sunt disponibile. Despre Cauta Porneste @@ -97,7 +95,7 @@ Instalat (%d) Actualizari (%d) Actualizari disponibile in F-Droid - +%1$d mai mult… + +%1$d mai mult… Nu a fost gasita o metoda Bluetooth de a trimite, alegeti una! Alegeti metoda de a trimite prin Bluetooth Trimite prin Bluetooth diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 164deaf8f..e26220762 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -45,8 +45,6 @@ Перезаписать Доступно Обновления - Доступно 1 обновление. - Доступно %d обновлений. Доступны обновления для F-Droid Методы отправки по Bluetooth не найдены, выберите какой-либо! Выберите метод отправки по Bluetooth @@ -212,7 +210,7 @@ Меньше Назад - Детали +%1$d … + Детали +%1$d … Неверный отпечаток ключа URL некорректен. Список изменений diff --git a/app/src/main/res/values-sc/strings.xml b/app/src/main/res/values-sc/strings.xml index b27a228ba..568c29aa7 100644 --- a/app/src/main/res/values-sc/strings.xml +++ b/app/src/main/res/values-sc/strings.xml @@ -45,8 +45,6 @@ Subraiscrie Disponìbiles Agiornamentos - 1 agiornamentu est disponìbile. - %d agiornamentos sunt disponìbiles. Agiornamentos pro F-Droid disponìbiles Perunu mètodu de imbiu Bluetooth agatadu, issèberane unu! Issèbera su mètodu de imbiu Bluetooth @@ -184,7 +182,7 @@ Depes Ligàmenes In dae segus - +%1$d àteru(os)… + +%1$d àteru(os)… Custu no est unu ligàmene vàlidu. Lista modìficas Agiornende sos depòsitos diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index f8ddc6acc..d95e46614 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -45,8 +45,6 @@ Prepísať Dostupné Aktualizácie - Dostupná 1 aktualizácia. - Dostupných %d aktualizácií. Dostupné aktualizácie F-Droidu Poslať cez Bluetooth Adresa repozitára @@ -187,7 +185,7 @@ znovu povoliť tento repozitár pre inštaláciu aplikácií z neho. Späť Nainštalované - +%1$d viac… + +%1$d viac… Posielanie cez Bluetooth zlyhalo, zvoľte inú metódu! Vyberte posielanie cez Bluetooth Tento repozitár je už nastavený a povolený. diff --git a/app/src/main/res/values-sn/strings.xml b/app/src/main/res/values-sn/strings.xml index 4b6c7189e..99b30f468 100644 --- a/app/src/main/res/values-sn/strings.xml +++ b/app/src/main/res/values-sn/strings.xml @@ -73,10 +73,8 @@ Zvekunatsa Zvavakirirwa (%d) Zvekunatsa (%d) - Chekunatsa 1 chiripo. - Zvekunatsa %d zviripo. Zvekunatsa F-Droid zviripo - zvimwe +%1$d … + zvimwe +%1$d … Hapana mutowo wekutumira neBluetooth wawanikwa, sarudza imwe chete! Sarudza mutowo weBluetooth wekutumira nawo Tumira kuburikira neBluetooth diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 5e872ecc4..6edaab0c9 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -44,8 +44,6 @@ Пребриши Доступне Надоградње - 1 надоградња је доступна. - %d нових надоградњи је доступно. Доступне су надоградње на Ф-дроиду Нема начина за слања блутутом, одредите један! Одредите начин слања блутутом @@ -188,7 +186,7 @@ Назад Инсталиране - +још %1$d… + +још %1$d… Лош отисак Ово није исправна адреса. Дневник измена diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b5ce211ad..ec7146078 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -45,8 +45,6 @@ Skriv över Tillgängliga Uppdateringar - 1 uppdatering finns tillgänglig. - %d uppdateringar finns tillgängliga. Uppdateringar för F-Droid tillgängliga Ingen metod för att kunna skicka med Bluetooth kunde hittas, välj en! Välj en sändningsmetod för Bluetooth @@ -191,7 +189,7 @@ Du kommer Bakåt Installerade - +%1$d mer… + +%1$d mer… Felaktigt fingeravtryck Det här är inte en giltig hemsideadress. Inställningar diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index c045e4335..586b60d61 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -55,8 +55,6 @@ மேம்படுத்தல்கள் நிறுவப்பட்டது (%d) மேம்படுத்தல்கள் (%d) - 1 மேம்படுத்தல் உள்ளது. - %d மேம்படுத்தல்கள் உள்ளன. F-Droid மேம்படுத்தல்கள் உள்ளன ப்ளூடூத் வழியாக அனுப்ப diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f85cf6149..4b1753e5a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -262,8 +262,6 @@ เพิ่มกุญแจเข้ารหัส เขียนทับ - 1 โปรแกรมมีอัพเดต - %d โปรแกรมมีอัพเดต แสดงรายละเอียดสถานะส่วนขยายช่วยติดตั้ง และ/หรือทำการอัพเดต/ถอนการติดตั้งส่วนขยายนี้ เลือกวิธีการส่งต่อผ่านบลูทูธ ยังไม่ได้เลือกวิธีส่งต่อทางบลูทูธ, โปรดเลือกก่อน! @@ -314,7 +312,7 @@ ไม่แสดงตัวบน Wi-Fi ไม่พบคนรอบข้างที่สามารถจะแบ่งปันโปรแกรมด้วยได้ QR Code ที่อ่านได้ ไม่ใช่โค้ดที่ใช้แบ่งปันโปรแกรม - มีอีก +%1$d… + มีอีก +%1$d… จัดให้โดย %1$s ไม่เจอคนที่ตามหาหรือ? แลกเปลี่ยนโปรแกรมกับคนข้างๆ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c395ba64e..d272a40e6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -45,8 +45,6 @@ Üzerine yaz Mevcut Güncellemeler - 1 güncelleme bulunmaktadır. - %d güncelleme bulunmaktadır. F-Droid güncellemeleri bulunmaktadır Hiçbir Bluetooth gönderme yöntemi bulunamadı, birisini seçin! Bluetooth gönderme yöntemi seç @@ -203,7 +201,7 @@ bu depoyu tekrar etkinleştirmeniz gerekecektir. Kurulu Kurulu (%d) Güncellemeler (%d) - +%1$d daha… + +%1$d daha… Yanlış parmak izi Bu, geçerli bir URL değildir. Ayarlar diff --git a/app/src/main/res/values-ug/strings.xml b/app/src/main/res/values-ug/strings.xml index d4ac3aee8..3adca7768 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -27,8 +27,6 @@ ۋاز كەچ ئىشلىتىشچان يېڭىلانمىلار - 1 يېڭىلانما بار. - %d يېڭىلانما بار. F-Droid يېڭىلانمىلىرى بار خەزىنە ئادرېسى خەزىنە يېڭىلا diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9b3c0a68d..b25caafe2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -87,10 +87,8 @@ Увімкнути Додати ключ Встановлено - Одне оновлення доступно. - %d оновлень доступно. F-Droid: доступні оновлення - +%1$d більше… + +%1$d більше… Надіслати через Bluetooth Поділитися diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 52bd4cf62..ddf6152ea 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -44,8 +44,6 @@ Ghi đè Hiện có Cập nhật - Có 1 bản cập nhật. - Có %d bản cập nhật. Có cập nhật F-Droid Không tìm thấy phương pháp gửi qua Bluetooth mặc định, hãy chọn phương pháp! Chọn phương pháp gửi qua Bluetooth @@ -168,7 +166,7 @@ Mã nguồn Không tương thích Đã cài đặt - +%1$d ứng dụng khác… + +%1$d ứng dụng khác… Đây không phải là URL hợp lệ. Lịch sử sửa đổi Bitcoin diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 414b45921..1db72a224 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -38,8 +38,6 @@ 覆盖 可安装 更新 - 有 1 个可用的更新。 - 有 %d 个可用的更新。 可更新 F-Droid 未找到蓝牙发送方式,请选择一个! 选择蓝牙发送方式 @@ -168,7 +166,7 @@ 已安装 已安装(%d) 可更新(%d) - +%1$d 更多… + +%1$d 更多… 用蓝牙发送 指纹错误 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index f92b5f6fd..238ed0fe3 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -41,8 +41,6 @@ 覆寫 可安裝 更新 - 您有 1 個更新。 - 您有 %d 個更新。 有尚未安裝的 F-Droid 更新 請選擇一個藍牙傳送的方式! 選擇藍牙的傳送方式 @@ -228,7 +226,7 @@ 您想更新此應用程式嗎? 您不會失去現有的數據, 已更新的程式亦不需要任何特別的存取權。 - 還有 +%1$d 個… + 還有 +%1$d 個… 下一步 %1$s 至 %2$s 應用程式的新版本使用了不同的鑰匙簽署。若要安裝新版本,您必須先將舊版本卸載,然後再嘗試安裝。(注意:卸載將會把應用程式內的資料刪除) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b5ac9b000..4bfb344d5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -64,8 +64,6 @@ 軟體更新 已安裝 (%d) 軟體更新 (%d) - 有 1 個軟體更新。 - 有 %d 個軟體更新。 有可用的 F-Droid 更新 透過藍牙傳送 @@ -257,7 +255,7 @@ 使用私密連線 空的使用者名稱,憑證未改變 - +%1$d 更多… + +%1$d 更多… 此應用軟體倉庫已經設立。確認您要重新啟用它。 進入的儲存庫已設立並已啟用。 略過異常的應用軟體倉庫 URI:%s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea70e6a6c..2976c7f7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,10 +88,7 @@ Updates Installed (%d) Updates (%d) - 1 update is available. - %d updates are available. F-Droid Updates Available - +%1$d more… No Bluetooth send method found, choose one! Choose Bluetooth send method Send via Bluetooth @@ -400,4 +397,30 @@ forcing the application to stop. Would you like to e-mail the details to help fix the issue? You can add extra information and comments here: + + + +%1$d more… + Update Available + Ready to install + Update ready to install + Install Failed + Downloading \"%1$s\"… + Downloading update for \"%1$s\"… + Installing \"%1$s\"… + Successfully installed + Install Failed + %1$d Updates + %1$d Apps Installed + Update available + Downloading… + Downloading update… + Ready to install + Update ready to install + Installing + Successfully installed + Install Failed + Update + Cancel + Install +