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

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

@@ -0,0 +1,50 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate;
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.ApkUpdateJob;
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.concurrent.TimeUnit;
public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener {
private static final String TAG = Log.tag(ApkUpdateRefreshListener.class);
private static final long INTERVAL = TimeUnit.HOURS.toMillis(6);
@Override
protected long getNextScheduledExecutionTime(Context context) {
return TextSecurePreferences.getUpdateApkRefreshTime(context);
}
@Override
protected long onAlarm(Context context, long scheduledTime) {
Log.i(TAG, "onAlarm...");
if (scheduledTime != 0 && BuildConfig.MANAGES_APP_UPDATES) {
Log.i(TAG, "Queueing APK update job...");
ApplicationDependencies.getJobManager().add(new ApkUpdateJob());
}
long newTime = System.currentTimeMillis() + INTERVAL;
TextSecurePreferences.setUpdateApkRefreshTime(context, newTime);
return newTime;
}
public static void schedule(Context context) {
new ApkUpdateRefreshListener().onReceive(context, getScheduleIntent());
}
}