Update website build to use PackageInstaller.

This commit is contained in:
Greyson Parrelli
2023-10-23 11:30:37 -07:00
committed by GitHub
parent d468d4c21b
commit 4b004f70ec
24 changed files with 759 additions and 458 deletions

View File

@@ -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\""
}

View File

@@ -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);
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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)
}
}
}

View File

@@ -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());

View File

@@ -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);
}
}
}

View File

@@ -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()
}
}

View File

@@ -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());
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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()
}
}
}