From 4b004f70ec37d13406fce47113a1874f1398311a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 23 Oct 2023 11:30:37 -0700 Subject: [PATCH] Update website build to use PackageInstaller. --- app/build.gradle | 15 +- .../securesms/ApplicationContext.java | 6 +- .../ApkUpdateDownloadManagerReceiver.kt | 42 +++ .../securesms/apkupdate/ApkUpdateInstaller.kt | 158 ++++++++++ .../ApkUpdateNotificationReceiver.kt | 43 +++ .../apkupdate/ApkUpdateNotifications.kt | 84 +++++ .../ApkUpdatePackageInstallerReceiver.kt | 75 +++++ .../ApkUpdateRefreshListener.java} | 20 +- .../securesms/components/RatingManager.java | 2 +- .../securesms/jobs/ApkUpdateJob.kt | 228 ++++++++++++++ .../securesms/jobs/JobManagerFactories.java | 2 +- .../securesms/jobs/UpdateApkJob.java | 291 ------------------ .../securesms/keyvalue/ApkUpdateValues.kt | 37 +++ .../securesms/keyvalue/SignalStore.java | 6 + .../notifications/NotificationChannels.java | 2 +- .../notifications/NotificationIds.java | 2 + .../service/UpdateApkReadyListener.java | 121 -------- .../securesms/util/PlayStoreUtil.java | 2 +- .../securesms/util/ServiceUtil.java | 5 + .../securesms/util/TextSecurePreferences.java | 18 -- app/src/main/res/values/strings.xml | 8 +- app/src/website/AndroidManifest.xml | 13 +- .../org/signal/core/util/ContextExtensions.kt | 13 + .../org/signal/core/util/CursorExtensions.kt | 24 ++ 24 files changed, 759 insertions(+), 458 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateDownloadManagerReceiver.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateInstaller.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotificationReceiver.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotifications.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdatePackageInstallerReceiver.kt rename app/src/main/java/org/thoughtcrime/securesms/{service/UpdateApkRefreshListener.java => apkupdate/ApkUpdateRefreshListener.java} (61%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/ApkUpdateJob.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/ApkUpdateValues.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java create mode 100644 core-util/src/main/java/org/signal/core/util/ContextExtensions.kt diff --git a/app/build.gradle b/app/build.gradle index bbbd4b3842..6809996efe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -319,26 +319,23 @@ android { play { dimension 'distribution' isDefault true - ext.websiteUpdateUrl = "null" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" - buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + buildConfigField "boolean", "MANAGES_APP_UPDATES", "false" + buildConfigField "String", "APK_UPDATE_URL", "null" buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\"" } website { dimension 'distribution' - ext.websiteUpdateUrl = "https://updates.signal.org/android" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" - buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\"" + buildConfigField "boolean", "MANAGES_APP_UPDATES", "true" + buildConfigField "String", "APK_UPDATE_URL", "\"https://updates.signal.org/android\"" buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\"" } nightly { dimension 'distribution' versionNameSuffix "-nightly-untagged-${getDateSuffix()}" - ext.websiteUpdateUrl = "null" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" - buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + buildConfigField "boolean", "MANAGES_APP_UPDATES", "false" + buildConfigField "String", "APK_UPDATE_URL", "null" buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 50f27d597e..76cbe7f75f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.LocalBackupListener; import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; -import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; +import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener; import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.AppForegroundObserver; @@ -399,8 +399,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr RotateSenderCertificateListener.schedule(this); RoutineMessageFetchReceiver.startOrUpdateAlarm(this); - if (BuildConfig.PLAY_STORE_DISABLED) { - UpdateApkRefreshListener.schedule(this); + if (BuildConfig.MANAGES_APP_UPDATES) { + ApkUpdateRefreshListener.schedule(this); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateDownloadManagerReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateDownloadManagerReceiver.kt new file mode 100644 index 0000000000..7a71aa8c56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateDownloadManagerReceiver.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Provided to the DownloadManager as a callback receiver for when it has finished downloading the APK we're trying to install. + * + * Registered in the manifest to list to [DownloadManager.ACTION_DOWNLOAD_COMPLETE]. + */ +class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ApkUpdateDownloadManagerReceiver::class.java) + } + + override fun onReceive(context: Context, intent: Intent) { + Log.i(TAG, "onReceive()") + + if (DownloadManager.ACTION_DOWNLOAD_COMPLETE != intent.action) { + Log.i(TAG, "Unexpected action: " + intent.action) + return + } + + val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) + if (downloadId != SignalStore.apkUpdate().downloadId) { + Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.") + return + } + + ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = false) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateInstaller.kt b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateInstaller.kt new file mode 100644 index 0000000000..a29a40278c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateInstaller.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import org.signal.core.util.PendingIntentFlags +import org.signal.core.util.StreamUtil +import org.signal.core.util.getDownloadManager +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.FileUtils +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest + +object ApkUpdateInstaller { + + private val TAG = Log.tag(ApkUpdateInstaller::class.java) + + /** + * Installs the downloaded APK silently if possible. If not, prompts the user with a notification to install. + * May show errors instead under certain conditions. + * + * A common pattern you may see is that this is called with [userInitiated] = false (or some other state + * that prevents us from auto-updating, like the app being in the foreground), causing this function + * to show an install prompt notification. The user clicks that notification, calling this with + * [userInitiated] = true, and then everything installs. + */ + fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) { + if (downloadId != SignalStore.apkUpdate().downloadId) { + Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.") + return + } + + val digest = SignalStore.apkUpdate().digest + if (digest == null) { + Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.") + SignalStore.apkUpdate().clearDownloadAttributes() + ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN) + return + } + + if (!isMatchingDigest(context, downloadId, digest)) { + Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.") + SignalStore.apkUpdate().clearDownloadAttributes() + ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN) + return + } + + if (!userInitiated && !shouldAutoUpdate()) { + Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${ApplicationDependencies.getAppForegroundObserver().isForegrounded}, AutoUpdate=${SignalStore.apkUpdate().autoUpdate})") + ApkUpdateNotifications.showInstallPrompt(context, downloadId) + return + } + + try { + installApk(context, downloadId, userInitiated) + } catch (e: IOException) { + Log.w(TAG, "Hit IOException when trying to install APK!", e) + SignalStore.apkUpdate().clearDownloadAttributes() + ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN) + } catch (e: SecurityException) { + Log.w(TAG, "Hit SecurityException when trying to install APK!", e) + SignalStore.apkUpdate().clearDownloadAttributes() + ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN) + } + } + + @Throws(IOException::class, SecurityException::class) + private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) { + val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId) + if (apkInputStream == null) { + Log.w(TAG, "Could not open download APK input stream!") + return + } + + Log.d(TAG, "Beginning APK install...") + val packageInstaller: PackageInstaller = context.packageManager.packageInstaller + + Log.d(TAG, "Clearing inactive sessions...") + packageInstaller.mySessions + .filter { session -> !session.isActive } + .forEach { session -> + try { + packageInstaller.abandonSession(session.sessionId) + } catch (e: SecurityException) { + Log.w(TAG, "Failed to abandon inactive session!", e) + } + } + + val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + // At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary. + // This lets us skip the system-generated notification. + if (Build.VERSION.SDK_INT >= 31) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + + Log.d(TAG, "Creating install session...") + val sessionId: Int = packageInstaller.createSession(sessionParams) + val session: PackageInstaller.Session = packageInstaller.openSession(sessionId) + + Log.d(TAG, "Writing APK data...") + session.use { activeSession -> + val sessionOutputStream = activeSession.openWrite(context.packageName, 0, -1) + StreamUtil.copy(apkInputStream, sessionOutputStream) + } + + val installerPendingIntent = PendingIntent.getBroadcast( + context, + sessionId, + Intent(context, ApkUpdatePackageInstallerReceiver::class.java).apply { + putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_USER_INITIATED, userInitiated) + putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_DOWNLOAD_ID, downloadId) + }, + PendingIntentFlags.mutable() or PendingIntentFlags.updateCurrent() + ) + + Log.d(TAG, "Committing session...") + session.commit(installerPendingIntent.intentSender) + } + + private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? { + return try { + FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor) + } catch (e: IOException) { + Log.w(TAG, e) + null + } + } + + private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean { + return try { + FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream -> + val digest = FileUtils.getFileDigest(stream) + MessageDigest.isEqual(digest, expectedDigest) + } + } catch (e: IOException) { + Log.w(TAG, e) + false + } + } + + private fun shouldAutoUpdate(): Boolean { + // TODO Auto-updates temporarily disabled. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this + return false +// return Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotificationReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotificationReceiver.kt new file mode 100644 index 0000000000..e8d4d4ef0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotificationReceiver.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.signal.core.util.logging.Log + +/** + * Receiver that is triggered based on various notification actions that can be taken on update-related notifications. + */ +class ApkUpdateNotificationReceiver : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ApkUpdateNotificationReceiver::class.java) + + const val ACTION_INITIATE_INSTALL = "signal.apk_update_notification.initiate_install" + const val EXTRA_DOWNLOAD_ID = "signal.download_id" + } + + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null) { + Log.w(TAG, "Null intent") + return + } + + val downloadId: Long = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2) + + when (val action: String? = intent.action) { + ACTION_INITIATE_INSTALL -> handleInstall(context, downloadId) + else -> Log.w(TAG, "Unrecognized notification action: $action") + } + } + + private fun handleInstall(context: Context, downloadId: Long) { + Log.i(TAG, "Got action to install.") + ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotifications.kt new file mode 100644 index 0000000000..76dc1a26b5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateNotifications.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import org.signal.core.util.PendingIntentFlags +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.util.ServiceUtil + +object ApkUpdateNotifications { + + val TAG = Log.tag(ApkUpdateNotifications::class.java) + + /** + * Shows a notification to prompt the user to install the app update. Only shown when silently auto-updating is not possible or are disabled by the user. + * Note: This is an 'ongoing' notification (i.e. not-user dismissable) and never dismissed programatically. This is because the act of installing the APK + * will dismiss it for us. + */ + @SuppressLint("LaunchActivityFromNotification") + fun showInstallPrompt(context: Context, downloadId: Long) { + val pendingIntent = PendingIntent.getBroadcast( + context, + 1, + Intent(context, ApkUpdateNotificationReceiver::class.java).apply { + action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL + putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId) + }, + PendingIntentFlags.immutable() + ) + + val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES) + .setOngoing(true) + .setContentTitle(context.getString(R.string.ApkUpdateNotifications_prompt_install_title)) + .setContentText(context.getString(R.string.ApkUpdateNotifications_prompt_install_body)) + .setSmallIcon(R.drawable.ic_notification) + .setColor(ContextCompat.getColor(context, R.color.core_ultramarine)) + .setContentIntent(pendingIntent) + .build() + + ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification) + } + + fun showInstallFailed(context: Context, reason: FailureReason) { + val pendingIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntentFlags.immutable() + ) + + val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES) + .setContentTitle(context.getString(R.string.ApkUpdateNotifications_failed_general_title)) + .setContentText(context.getString(R.string.ApkUpdateNotifications_failed_general_body)) + .setSmallIcon(R.drawable.ic_notification) + .setColor(ContextCompat.getColor(context, R.color.core_ultramarine)) + .setContentIntent(pendingIntent) + .build() + + ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification) + } + + enum class FailureReason { + UNKNOWN, + ABORTED, + BLOCKED, + INCOMPATIBLE, + INVALID, + CONFLICT, + STORAGE, + TIMEOUT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdatePackageInstallerReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdatePackageInstallerReceiver.kt new file mode 100644 index 0000000000..9deb2c4b98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdatePackageInstallerReceiver.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import org.signal.core.util.getParcelableExtraCompat +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.apkupdate.ApkUpdateNotifications.FailureReason + +/** + * This is the receiver that is triggered by the [PackageInstaller] to notify of various events. Package installation is initiated + * in [ApkUpdateInstaller]. + */ +class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ApkUpdatePackageInstallerReceiver::class.java) + + const val EXTRA_USER_INITIATED = "signal.user_initiated" + const val EXTRA_DOWNLOAD_ID = "signal.download_id" + } + + override fun onReceive(context: Context, intent: Intent?) { + val statusCode: Int = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: -1 + val statusMessage: String? = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val userInitiated = intent?.getBooleanExtra(EXTRA_USER_INITIATED, false) ?: false + + Log.w(TAG, "[onReceive] Status: $statusCode, Message: $statusMessage") + + when (statusCode) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!) + PackageInstaller.STATUS_SUCCESS -> Log.w(TAG, "Update installed successfully!") + PackageInstaller.STATUS_FAILURE_ABORTED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.ABORTED) + PackageInstaller.STATUS_FAILURE_BLOCKED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.BLOCKED) + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INCOMPATIBLE) + PackageInstaller.STATUS_FAILURE_INVALID -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INVALID) + PackageInstaller.STATUS_FAILURE_CONFLICT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.CONFLICT) + PackageInstaller.STATUS_FAILURE_STORAGE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.STORAGE) + PackageInstaller.STATUS_FAILURE_TIMEOUT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.TIMEOUT) + PackageInstaller.STATUS_FAILURE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.UNKNOWN) + else -> Log.w(TAG, "Unknown status! $statusCode") + } + } + + private fun handlePendingUserAction(context: Context, userInitiated: Boolean, intent: Intent) { + val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2) + + if (!userInitiated) { + Log.w(TAG, "Not user-initiated, but needs user action! Showing prompt notification.") + ApkUpdateNotifications.showInstallPrompt(context, downloadId) + return + } + + val promptIntent: Intent? = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT, Intent::class.java) + if (promptIntent == null) { + Log.w(TAG, "Missing prompt intent! Showing prompt notification instead.") + ApkUpdateNotifications.showInstallPrompt(context, downloadId) + return + } + + promptIntent.apply { + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(promptIntent) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateRefreshListener.java similarity index 61% rename from app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java rename to app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateRefreshListener.java index ac7f9291f9..8ff760dffb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/apkupdate/ApkUpdateRefreshListener.java @@ -1,4 +1,9 @@ -package org.thoughtcrime.securesms.service; +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.apkupdate; import android.content.Context; @@ -6,14 +11,15 @@ import android.content.Context; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.UpdateApkJob; +import org.thoughtcrime.securesms.jobs.ApkUpdateJob; +import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.concurrent.TimeUnit; -public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { +public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener { - private static final String TAG = Log.tag(UpdateApkRefreshListener.class); + private static final String TAG = Log.tag(ApkUpdateRefreshListener.class); private static final long INTERVAL = TimeUnit.HOURS.toMillis(6); @@ -26,9 +32,9 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { protected long onAlarm(Context context, long scheduledTime) { Log.i(TAG, "onAlarm..."); - if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) { + if (scheduledTime != 0 && BuildConfig.MANAGES_APP_UPDATES) { Log.i(TAG, "Queueing APK update job..."); - ApplicationDependencies.getJobManager().add(new UpdateApkJob()); + ApplicationDependencies.getJobManager().add(new ApkUpdateJob()); } long newTime = System.currentTimeMillis() + INTERVAL; @@ -38,7 +44,7 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { } public static void schedule(Context context) { - new UpdateApkRefreshListener().onReceive(context, getScheduleIntent()); + new ApkUpdateRefreshListener().onReceive(context, getScheduleIntent()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java index 0b998c3543..9b3d1f3cb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java @@ -22,7 +22,7 @@ public class RatingManager { private static final String TAG = Log.tag(RatingManager.class); public static void showRatingDialogIfNecessary(Context context) { - if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.PLAY_STORE_DISABLED) return; + if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.MANAGES_APP_UPDATES) return; long daysSinceInstall = VersionTracker.getDaysSinceFirstInstalled(context); long laterTimestamp = TextSecurePreferences.getRatingLaterTimestamp(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ApkUpdateJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ApkUpdateJob.kt new file mode 100644 index 0000000000..846969b0c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ApkUpdateJob.kt @@ -0,0 +1,228 @@ +package org.thoughtcrime.securesms.jobs + +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import okhttp3.OkHttpClient +import okhttp3.Request +import org.signal.core.util.Hex +import org.signal.core.util.forEach +import org.signal.core.util.getDownloadManager +import org.signal.core.util.logging.Log +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireString +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.apkupdate.ApkUpdateDownloadManagerReceiver +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.FileUtils +import org.thoughtcrime.securesms.util.JsonUtils +import java.io.FileInputStream +import java.io.IOException +import java.security.MessageDigest + +/** + * Designed to be a periodic job that checks for new app updates when the user is running a build that + * is distributed outside of the play store (like our website build). + * + * It uses the DownloadManager to actually download the APK for some easy reliability, considering the + * file it's downloading it rather large (70+ MB). + */ +class ApkUpdateJob private constructor(parameters: Parameters) : BaseJob(parameters) { + + companion object { + const val KEY = "UpdateApkJob" + private val TAG = Log.tag(ApkUpdateJob::class.java) + } + + constructor() : this( + Parameters.Builder() + .setQueue(KEY) + .setMaxInstancesForFactory(2) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(2) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + @Throws(IOException::class) + public override fun onRun() { + if (!BuildConfig.MANAGES_APP_UPDATES) { + Log.w(TAG, "Not an app-updating build! Exiting.") + return + } + + Log.i(TAG, "Checking for APK update...") + + val client = OkHttpClient() + val request = Request.Builder().url("${BuildConfig.APK_UPDATE_URL}/latest.json").build() + + val rawUpdateDescriptor: String = client.newCall(request).execute().use { response -> + if (!response.isSuccessful || response.body() == null) { + throw IOException("Failed to read update descriptor") + } + response.body()!!.string() + } + + val updateDescriptor: UpdateDescriptor = JsonUtils.fromJson(rawUpdateDescriptor, UpdateDescriptor::class.java) + + if (updateDescriptor.versionCode <= 0 || updateDescriptor.versionName == null || updateDescriptor.url == null || updateDescriptor.digest == null) { + Log.w(TAG, "Invalid update descriptor! $updateDescriptor") + return + } else { + Log.i(TAG, "Got descriptor: $updateDescriptor") + } + + if (updateDescriptor.versionCode > getCurrentAppVersionCode()) { + val digest: ByteArray = Hex.fromStringCondensed(updateDescriptor.digest) + val downloadStatus: DownloadStatus = getDownloadStatus(updateDescriptor.url, digest) + + Log.i(TAG, "Download status: ${downloadStatus.status}") + + if (downloadStatus.status == DownloadStatus.Status.COMPLETE) { + Log.i(TAG, "Download status complete, notifying...") + handleDownloadComplete(downloadStatus.downloadId) + } else if (downloadStatus.status == DownloadStatus.Status.MISSING) { + Log.i(TAG, "Download status missing, starting download...") + handleDownloadStart(updateDescriptor.url, updateDescriptor.versionName, digest) + } + } + } + + public override fun onShouldRetry(e: Exception): Boolean { + return e is IOException + } + + override fun onFailure() { + Log.w(TAG, "Update check failed") + } + + @Throws(PackageManager.NameNotFoundException::class) + private fun getCurrentAppVersionCode(): Int { + val packageManager = context.packageManager + val packageInfo = packageManager.getPackageInfo(context.packageName, 0) + return packageInfo.versionCode + } + + private fun getDownloadStatus(uri: String, remoteDigest: ByteArray): DownloadStatus { + val pendingDownloadId: Long = SignalStore.apkUpdate().downloadId + val pendingDigest: ByteArray? = SignalStore.apkUpdate().digest + + if (pendingDownloadId == -1L || pendingDigest == null || !MessageDigest.isEqual(pendingDigest, remoteDigest)) { + SignalStore.apkUpdate().clearDownloadAttributes() + return DownloadStatus(DownloadStatus.Status.MISSING, -1) + } + + val query = DownloadManager.Query().apply { + setFilterByStatus(DownloadManager.STATUS_PAUSED or DownloadManager.STATUS_PENDING or DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_SUCCESSFUL) + setFilterById(pendingDownloadId) + } + + context.getDownloadManager().query(query).forEach { cursor -> + val jobStatus = cursor.requireInt(DownloadManager.COLUMN_STATUS) + val jobRemoteUri = cursor.requireString(DownloadManager.COLUMN_URI) + val downloadId = cursor.requireLong(DownloadManager.COLUMN_ID) + + if (jobRemoteUri == uri && downloadId == pendingDownloadId) { + return if (jobStatus == DownloadManager.STATUS_SUCCESSFUL) { + val digest = getDigestForDownloadId(downloadId) + if (digest != null && MessageDigest.isEqual(digest, remoteDigest)) { + DownloadStatus(DownloadStatus.Status.COMPLETE, downloadId) + } else { + Log.w(TAG, "Found downloadId $downloadId, but the digest doesn't match! Considering it missing.") + SignalStore.apkUpdate().clearDownloadAttributes() + DownloadStatus(DownloadStatus.Status.MISSING, downloadId) + } + } else { + DownloadStatus(DownloadStatus.Status.PENDING, downloadId) + } + } + } + + return DownloadStatus(DownloadStatus.Status.MISSING, -1) + } + + private fun handleDownloadStart(uri: String?, versionName: String?, digest: ByteArray) { + deleteExistingDownloadedApks(context) + + val downloadRequest = DownloadManager.Request(Uri.parse(uri)).apply { + setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI) + setTitle("Downloading Signal update") + setDescription("Downloading Signal $versionName") + setDestinationInExternalFilesDir(context, null, "signal-update.apk") + setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + } + + val downloadId = context.getDownloadManager().enqueue(downloadRequest) + // DownloadManager will trigger [UpdateApkReadyListener] when finished via a broadcast + + SignalStore.apkUpdate().setDownloadAttributes(downloadId, digest) + } + + private fun handleDownloadComplete(downloadId: Long) { + val intent = Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId) + ApkUpdateDownloadManagerReceiver().onReceive(context, intent) + } + + private fun getDigestForDownloadId(downloadId: Long): ByteArray? { + return try { + FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream -> + FileUtils.getFileDigest(stream) + } + } catch (e: IOException) { + Log.w(TAG, "Failed to get digest for downloadId! $downloadId", e) + null + } + } + + private fun deleteExistingDownloadedApks(context: Context) { + val directory = context.getExternalFilesDir(null) + if (directory == null) { + Log.w(TAG, "Failed to read external files directory.") + return + } + + for (file in directory.listFiles() ?: emptyArray()) { + if (file.name.startsWith("signal-update")) { + if (file.delete()) { + Log.d(TAG, "Deleted " + file.name) + } + } + } + } + + private data class UpdateDescriptor( + @JsonProperty + val versionCode: Int = 0, + + @JsonProperty + val versionName: String? = null, + + @JsonProperty + val url: String? = null, + + @JsonProperty("sha256sum") + val digest: String? = null + ) + + private class DownloadStatus(val status: Status, val downloadId: Long) { + enum class Status { + PENDING, COMPLETE, MISSING + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): ApkUpdateJob { + return ApkUpdateJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index bbff66c7c6..c51e795896 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -217,7 +217,7 @@ public final class JobManagerFactories { put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory()); - put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); + put(ApkUpdateJob.KEY, new ApkUpdateJob.Factory()); // Migrations put(AccountConsistencyMigrationJob.KEY, new AccountConsistencyMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java deleted file mode 100644 index 1f2c25da78..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java +++ /dev/null @@ -1,291 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - - -import android.app.DownloadManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.BuildConfig; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.service.UpdateApkReadyListener; -import org.thoughtcrime.securesms.util.FileUtils; -import org.signal.core.util.Hex; -import org.thoughtcrime.securesms.util.JsonUtils; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public class UpdateApkJob extends BaseJob { - - public static final String KEY = "UpdateApkJob"; - - private static final String TAG = Log.tag(UpdateApkJob.class); - - public UpdateApkJob() { - this(new Job.Parameters.Builder() - .setQueue("UpdateApkJob") - .addConstraint(NetworkConstraint.KEY) - .setMaxAttempts(2) - .build()); - } - - private UpdateApkJob(@NonNull Job.Parameters parameters) { - super(parameters); - } - - @Override - public @Nullable byte[] serialize() { - return null; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws IOException, PackageManager.NameNotFoundException { - if (!BuildConfig.PLAY_STORE_DISABLED) return; - - Log.i(TAG, "Checking for APK update..."); - - OkHttpClient client = new OkHttpClient(); - Request request = new Request.Builder().url(String.format("%s/latest.json", BuildConfig.NOPLAY_UPDATE_URL)).build(); - - try (Response response = client.newCall(request).execute()) { - - if (!response.isSuccessful() || response.body() == null) { - throw new IOException("Bad response: " + response.message()); - } - - UpdateDescriptor updateDescriptor = JsonUtils.fromJson(response.body().string(), UpdateDescriptor.class); - byte[] digest = Hex.fromStringCondensed(updateDescriptor.getDigest()); - - Log.i(TAG, "Got descriptor: " + updateDescriptor); - - if (updateDescriptor.getVersionCode() > getVersionCode()) { - DownloadStatus downloadStatus = getDownloadStatus(updateDescriptor.getUrl(), digest); - - Log.i(TAG, "Download status: " + downloadStatus.getStatus()); - - if (downloadStatus.getStatus() == DownloadStatus.Status.COMPLETE) { - Log.i(TAG, "Download status complete, notifying..."); - handleDownloadNotify(downloadStatus.getDownloadId()); - } else if (downloadStatus.getStatus() == DownloadStatus.Status.MISSING) { - Log.i(TAG, "Download status missing, starting download..."); - handleDownloadStart(updateDescriptor.getUrl(), updateDescriptor.getVersionName(), digest); - } - } - } - } - - @Override - public boolean onShouldRetry(@NonNull Exception e) { - return e instanceof IOException; - } - - @Override - public void onFailure() { - Log.w(TAG, "Update check failed"); - } - - private int getVersionCode() throws PackageManager.NameNotFoundException { - PackageManager packageManager = context.getPackageManager(); - PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); - - return packageInfo.versionCode; - } - - private DownloadStatus getDownloadStatus(String uri, byte[] theirDigest) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Query query = new DownloadManager.Query(); - - query.setFilterByStatus(DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_PENDING | DownloadManager.STATUS_RUNNING | DownloadManager.STATUS_SUCCESSFUL); - - long pendingDownloadId = TextSecurePreferences.getUpdateApkDownloadId(context); - byte[] pendingDigest = getPendingDigest(context); - Cursor cursor = downloadManager.query(query); - - try { - DownloadStatus status = new DownloadStatus(DownloadStatus.Status.MISSING, -1); - - while (cursor != null && cursor.moveToNext()) { - int jobStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); - String jobRemoteUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)); - long downloadId = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); - byte[] digest = getDigestForDownloadId(downloadId); - - if (jobRemoteUri != null && jobRemoteUri.equals(uri) && downloadId == pendingDownloadId) { - - if (jobStatus == DownloadManager.STATUS_SUCCESSFUL && - digest != null && pendingDigest != null && - MessageDigest.isEqual(pendingDigest, theirDigest) && - MessageDigest.isEqual(digest, theirDigest)) - { - return new DownloadStatus(DownloadStatus.Status.COMPLETE, downloadId); - } else if (jobStatus != DownloadManager.STATUS_SUCCESSFUL) { - status = new DownloadStatus(DownloadStatus.Status.PENDING, downloadId); - } - } - } - - return status; - } finally { - if (cursor != null) cursor.close(); - } - } - - private void handleDownloadStart(String uri, String versionName, byte[] digest) { - clearPreviousDownloads(context); - - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Request downloadRequest = new DownloadManager.Request(Uri.parse(uri)); - - downloadRequest.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI); - downloadRequest.setTitle("Downloading Signal update"); - downloadRequest.setDescription("Downloading Signal " + versionName); - downloadRequest.setVisibleInDownloadsUi(false); - downloadRequest.setDestinationInExternalFilesDir(context, null, "signal-update.apk"); - downloadRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); - - long downloadId = downloadManager.enqueue(downloadRequest); - TextSecurePreferences.setUpdateApkDownloadId(context, downloadId); - TextSecurePreferences.setUpdateApkDigest(context, Hex.toStringCondensed(digest)); - } - - private void handleDownloadNotify(long downloadId) { - Intent intent = new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE); - intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId); - - new UpdateApkReadyListener().onReceive(context, intent); - } - - private @Nullable byte[] getDigestForDownloadId(long downloadId) { - try { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); - byte[] digest = FileUtils.getFileDigest(fin); - - fin.close(); - - return digest; - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - private @Nullable byte[] getPendingDigest(Context context) { - try { - String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); - - if (encodedDigest == null) return null; - - return Hex.fromStringCondensed(encodedDigest); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - private static void clearPreviousDownloads(@NonNull Context context) { - File directory = context.getExternalFilesDir(null); - - if (directory == null) { - Log.w(TAG, "Failed to read external files directory."); - return; - } - - for (File file : directory.listFiles()) { - if (file.getName().startsWith("signal-update")) { - if (file.delete()) { - Log.d(TAG, "Deleted " + file.getName()); - } - } - } - } - - private static class UpdateDescriptor { - @JsonProperty - private int versionCode; - - @JsonProperty - private String versionName; - - @JsonProperty - private String url; - - @JsonProperty - private String sha256sum; - - - public int getVersionCode() { - return versionCode; - } - - public String getVersionName() { - return versionName; - } - - public String getUrl() { - return url; - } - - public @NonNull String toString() { - return "[" + versionCode + ", " + versionName + ", " + url + "]"; - } - - public String getDigest() { - return sha256sum; - } - } - - private static class DownloadStatus { - enum Status { - PENDING, - COMPLETE, - MISSING - } - - private final Status status; - private final long downloadId; - - DownloadStatus(Status status, long downloadId) { - this.status = status; - this.downloadId = downloadId; - } - - public Status getStatus() { - return status; - } - - public long getDownloadId() { - return downloadId; - } - } - - public static final class Factory implements Job.Factory { - @Override - public @NonNull UpdateApkJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) { - return new UpdateApkJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ApkUpdateValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ApkUpdateValues.kt new file mode 100644 index 0000000000..a86e07201a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ApkUpdateValues.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.keyvalue + +internal class ApkUpdateValues(store: KeyValueStore) : SignalStoreValues(store) { + companion object { + private const val DOWNLOAD_ID = "apk_update.download_id" + private const val DIGEST = "apk_update.digest" + private const val AUTO_UPDATE = "apk_update.auto_update" + } + + override fun onFirstEverAppLaunch() = Unit + override fun getKeysToIncludeInBackup(): List = emptyList() + + val downloadId: Long by longValue(DOWNLOAD_ID, -2) + val digest: ByteArray? get() = store.getBlob(DIGEST, null) + val autoUpdate: Boolean by booleanValue(AUTO_UPDATE, true) + + fun setDownloadAttributes(id: Long, digest: ByteArray?) { + store + .beginWrite() + .putLong(DOWNLOAD_ID, id) + .putBlob(DIGEST, digest) + .commit() + } + + fun clearDownloadAttributes() { + store + .beginWrite() + .putLong(DOWNLOAD_ID, -1) + .putBlob(DIGEST, null) + .commit() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 537ad26128..adc7f6ca73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -43,6 +43,7 @@ public final class SignalStore { private final NotificationProfileValues notificationProfileValues; private final ReleaseChannelValues releaseChannelValues; private final StoryValues storyValues; + private final ApkUpdateValues apkUpdate; private final PlainTextSharedPrefsDataStore plainTextValues; @@ -87,6 +88,7 @@ public final class SignalStore { this.notificationProfileValues = new NotificationProfileValues(store); this.releaseChannelValues = new ReleaseChannelValues(store); this.storyValues = new StoryValues(store); + this.apkUpdate = new ApkUpdateValues(store); this.plainTextValues = new PlainTextSharedPrefsDataStore(ApplicationDependencies.getApplication()); } @@ -264,6 +266,10 @@ public final class SignalStore { return getInstance().storyValues; } + public static @NonNull ApkUpdateValues apkUpdate() { + return getInstance().apkUpdate; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AciAuthorizationCache() { return GroupsV2AuthorizationSignalStoreCache.createAciCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 3eb9fb0a9b..a9fd327311 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -653,7 +653,7 @@ public class NotificationChannels { notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes, joinEvents, background, callStatus, appAlerts, additionalMessageNotifications)); - if (BuildConfig.PLAY_STORE_DISABLED) { + if (BuildConfig.MANAGES_APP_UPDATES) { NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_DEFAULT); notificationManager.createNotificationChannel(appUpdates); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index bad7fc4396..02b74fe1d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -7,6 +7,8 @@ import org.thoughtcrime.securesms.notifications.v2.ConversationId; public final class NotificationIds { public static final int FCM_FAILURE = 12; + public static final int APK_UPDATE_PROMPT_INSTALL = 666; + public static final int APK_UPDATE_FAILED_INSTALL = 667; public static final int PENDING_MESSAGES = 1111; public static final int MESSAGE_SUMMARY = 1338; public static final int APPLICATION_MIGRATION = 4242; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java deleted file mode 100644 index 0a26dd852d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ /dev/null @@ -1,121 +0,0 @@ -package org.thoughtcrime.securesms.service; - - -import android.app.DownloadManager; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; - -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import org.signal.core.util.PendingIntentFlags; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.util.FileProviderUtil; -import org.thoughtcrime.securesms.util.FileUtils; -import org.signal.core.util.Hex; -import org.thoughtcrime.securesms.util.ServiceUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; - -public class UpdateApkReadyListener extends BroadcastReceiver { - - private static final String TAG = Log.tag(UpdateApkReadyListener.class); - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "onReceive()"); - - if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { - long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2); - - if (downloadId == TextSecurePreferences.getUpdateApkDownloadId(context)) { - Uri uri = getLocalUriForDownloadId(context, downloadId); - String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); - - if (uri == null) { - Log.w(TAG, "Downloaded local URI is null?"); - return; - } - - if (isMatchingDigest(context, downloadId, encodedDigest)) { - displayInstallNotification(context, uri); - } else { - Log.w(TAG, "Downloaded APK doesn't match digest..."); - } - } - } - } - - private void displayInstallNotification(Context context, Uri uri) { - Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE); - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.setData(uri); - - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntentFlags.mutable()); - - Notification notification = new NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES) - .setOngoing(true) - .setContentTitle(context.getString(R.string.UpdateApkReadyListener_Signal_update)) - .setContentText(context.getString(R.string.UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update)) - .setSmallIcon(R.drawable.ic_notification) - .setColor(context.getResources().getColor(R.color.core_ultramarine)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_REMINDER) - .setContentIntent(pendingIntent) - .build(); - - ServiceUtil.getNotificationManager(context).notify(666, notification); - } - - private @Nullable Uri getLocalUriForDownloadId(Context context, long downloadId) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Query query = new DownloadManager.Query(); - query.setFilterById(downloadId); - - Cursor cursor = downloadManager.query(query); - - try { - if (cursor != null && cursor.moveToFirst()) { - String localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); - - if (localUri != null) { - File localFile = new File(Uri.parse(localUri).getPath()); - return FileProviderUtil.getUriFor(context, localFile); - } - } - } finally { - if (cursor != null) cursor.close(); - } - - return null; - } - - private boolean isMatchingDigest(Context context, long downloadId, String theirEncodedDigest) { - try { - if (theirEncodedDigest == null) return false; - - byte[] theirDigest = Hex.fromStringCondensed(theirEncodedDigest); - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); - byte[] ourDigest = FileUtils.getFileDigest(fin); - - fin.close(); - - return MessageDigest.isEqual(ourDigest, theirDigest); - } catch (IOException e) { - Log.w(TAG, e); - return false; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java index 0085fa628b..745e6a1ac1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java @@ -14,7 +14,7 @@ public final class PlayStoreUtil { private PlayStoreUtil() {} public static void openPlayStoreOrOurApkDownloadPage(@NonNull Context context) { - if (BuildConfig.PLAY_STORE_DISABLED) { + if (BuildConfig.MANAGES_APP_UPDATES) { CommunicationActions.openBrowserLink(context, "https://signal.org/android/apk"); } else { openPlayStore(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java index ac81b7c50b..f94a689d23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util; import android.app.Activity; import android.app.ActivityManager; import android.app.AlarmManager; +import android.app.DownloadManager; import android.app.KeyguardManager; import android.app.NotificationManager; import android.app.job.JobScheduler; @@ -112,4 +113,8 @@ public class ServiceUtil { public static BluetoothManager getBluetoothManager(@NotNull Context context) { return ContextCompat.getSystemService(context, BluetoothManager.class); } + + public static DownloadManager getDownloadManager(@NonNull Context context) { + return ContextCompat.getSystemService(context, DownloadManager.class); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index ecab8b5e4b..886d8616a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -83,8 +83,6 @@ public class TextSecurePreferences { private static final String PROMPTED_OPTIMIZE_DOZE_PREF = "pref_prompted_optimize_doze"; private static final String DIRECTORY_FRESH_TIME_PREF = "pref_directory_refresh_time"; private static final String UPDATE_APK_REFRESH_TIME_PREF = "pref_update_apk_refresh_time"; - private static final String UPDATE_APK_DOWNLOAD_ID = "pref_update_apk_download_id"; - private static final String UPDATE_APK_DIGEST = "pref_update_apk_digest"; private static final String SIGNED_PREKEY_ROTATION_TIME_PREF = "pref_signed_pre_key_rotation_time"; private static final String IN_THREAD_NOTIFICATION_PREF = "pref_key_inthread_notifications"; @@ -624,22 +622,6 @@ public class TextSecurePreferences { setLongPreference(context, UPDATE_APK_REFRESH_TIME_PREF, value); } - public static void setUpdateApkDownloadId(Context context, long value) { - setLongPreference(context, UPDATE_APK_DOWNLOAD_ID, value); - } - - public static long getUpdateApkDownloadId(Context context) { - return getLongPreference(context, UPDATE_APK_DOWNLOAD_ID, -1); - } - - public static void setUpdateApkDigest(Context context, String value) { - setStringPreference(context, UPDATE_APK_DIGEST, value); - } - - public static String getUpdateApkDigest(Context context) { - return getStringPreference(context, UPDATE_APK_DIGEST, null); - } - public static boolean isEnterImeKeyEnabled(Context context) { return getBooleanPreference(context, ENTER_PRESENT_PREF, false); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3721b4a934..653e3d482b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2099,9 +2099,11 @@ %1$s belongs to %2$s - - Signal update - A new version of Signal is available, tap to update + + Signal update + A new version of Signal is available. Tap to update. + Signal failed to update + We will try again later. Send message? diff --git a/app/src/website/AndroidManifest.xml b/app/src/website/AndroidManifest.xml index 51e0ac1f05..613e309f7f 100644 --- a/app/src/website/AndroidManifest.xml +++ b/app/src/website/AndroidManifest.xml @@ -2,18 +2,27 @@ + - + - + + + + + \ No newline at end of file diff --git a/core-util/src/main/java/org/signal/core/util/ContextExtensions.kt b/core-util/src/main/java/org/signal/core/util/ContextExtensions.kt new file mode 100644 index 0000000000..99fa3e5de4 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/ContextExtensions.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util + +import android.app.DownloadManager +import android.content.Context + +fun Context.getDownloadManager(): DownloadManager { + return this.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager +} diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 54bbcd53bc..80a4cd72bc 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -181,6 +181,10 @@ inline fun Cursor.forEach(operation: (Cursor) -> Unit) { } } +fun Cursor.iterable(): Iterable { + return CursorIterable(this) +} + fun Boolean.toInt(): Int = if (this) 1 else 0 /** @@ -202,3 +206,23 @@ fun Cursor.rowToString(): String { return builder.toString() } + +private class CursorIterable(private val cursor: Cursor) : Iterable { + override fun iterator(): Iterator { + return CursorIterator(cursor) + } +} + +private class CursorIterator(private val cursor: Cursor) : Iterator { + override fun hasNext(): Boolean { + return !cursor.isClosed && cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast + } + + override fun next(): Cursor { + return if (cursor.moveToNext()) { + cursor + } else { + throw NoSuchElementException() + } + } +}