mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 20:48:43 +00:00
Update website build to use PackageInstaller.
This commit is contained in:
@@ -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\""
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ApkUpdateJob?> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): ApkUpdateJob {
|
||||
return ApkUpdateJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<UpdateApkJob> {
|
||||
@Override
|
||||
public @NonNull UpdateApkJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
|
||||
return new UpdateApkJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2099,9 +2099,11 @@
|
||||
<!-- Displayed in the conversation list when identities have been merged. The first placeholder is a phone number, and the second is a person\'s name -->
|
||||
<string name="ThreadRecord_s_belongs_to_s">%1$s belongs to %2$s</string>
|
||||
|
||||
<!-- UpdateApkReadyListener -->
|
||||
<string name="UpdateApkReadyListener_Signal_update">Signal update</string>
|
||||
<string name="UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update">A new version of Signal is available, tap to update</string>
|
||||
<!-- ApkUpdateNotifications -->
|
||||
<string name="ApkUpdateNotifications_prompt_install_title">Signal update</string>
|
||||
<string name="ApkUpdateNotifications_prompt_install_body">A new version of Signal is available. Tap to update.</string>
|
||||
<string name="ApkUpdateNotifications_failed_general_title">Signal failed to update</string>
|
||||
<string name="ApkUpdateNotifications_failed_general_body">We will try again later.</string>
|
||||
|
||||
<!-- UntrustedSendDialog -->
|
||||
<string name="UntrustedSendDialog_send_message">Send message?</string>
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION"/>
|
||||
|
||||
<application>
|
||||
<receiver android:name=".service.UpdateApkRefreshListener" android:exported="false">
|
||||
<receiver android:name=".apkupdate.ApkUpdateRefreshListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.UpdateApkReadyListener" android:exported="false">
|
||||
<receiver android:name=".apkupdate.ApkUpdateDownloadManagerReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".apkupdate.ApkUpdatePackageInstallerReceiver"
|
||||
android:exported="true" />
|
||||
|
||||
<receiver
|
||||
android:name=".apkupdate.ApkUpdateNotificationReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
@@ -181,6 +181,10 @@ inline fun Cursor.forEach(operation: (Cursor) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun Cursor.iterable(): Iterable<Cursor> {
|
||||
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<Cursor> {
|
||||
override fun iterator(): Iterator<Cursor> {
|
||||
return CursorIterator(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
private class CursorIterator(private val cursor: Cursor) : Iterator<Cursor> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user