Skip to content

Commit 4b004f7

Browse files
Update website build to use PackageInstaller.
1 parent d468d4c commit 4b004f7

24 files changed

Lines changed: 759 additions & 458 deletions

app/build.gradle

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -319,26 +319,23 @@ android {
319319
play {
320320
dimension 'distribution'
321321
isDefault true
322-
ext.websiteUpdateUrl = "null"
323-
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
324-
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
322+
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
323+
buildConfigField "String", "APK_UPDATE_URL", "null"
325324
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
326325
}
327326

328327
website {
329328
dimension 'distribution'
330-
ext.websiteUpdateUrl = "https://updates.signal.org/android"
331-
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
332-
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
329+
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
330+
buildConfigField "String", "APK_UPDATE_URL", "\"https://updates.signal.org/android\""
333331
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
334332
}
335333

336334
nightly {
337335
dimension 'distribution'
338336
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
339-
ext.websiteUpdateUrl = "null"
340-
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
341-
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
337+
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
338+
buildConfigField "String", "APK_UPDATE_URL", "null"
342339
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
343340
}
344341

app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
import org.thoughtcrime.securesms.service.LocalBackupListener;
8585
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
8686
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
87-
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
87+
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
8888
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
8989
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
9090
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -399,8 +399,8 @@ private void initializePeriodicTasks() {
399399
RotateSenderCertificateListener.schedule(this);
400400
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
401401

402-
if (BuildConfig.PLAY_STORE_DISABLED) {
403-
UpdateApkRefreshListener.schedule(this);
402+
if (BuildConfig.MANAGES_APP_UPDATES) {
403+
ApkUpdateRefreshListener.schedule(this);
404404
}
405405
}
406406

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2023 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.apkupdate
7+
8+
import android.app.DownloadManager
9+
import android.content.BroadcastReceiver
10+
import android.content.Context
11+
import android.content.Intent
12+
import org.signal.core.util.logging.Log
13+
import org.thoughtcrime.securesms.keyvalue.SignalStore
14+
15+
/**
16+
* Provided to the DownloadManager as a callback receiver for when it has finished downloading the APK we're trying to install.
17+
*
18+
* Registered in the manifest to list to [DownloadManager.ACTION_DOWNLOAD_COMPLETE].
19+
*/
20+
class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() {
21+
22+
companion object {
23+
private val TAG = Log.tag(ApkUpdateDownloadManagerReceiver::class.java)
24+
}
25+
26+
override fun onReceive(context: Context, intent: Intent) {
27+
Log.i(TAG, "onReceive()")
28+
29+
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE != intent.action) {
30+
Log.i(TAG, "Unexpected action: " + intent.action)
31+
return
32+
}
33+
34+
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2)
35+
if (downloadId != SignalStore.apkUpdate().downloadId) {
36+
Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.")
37+
return
38+
}
39+
40+
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = false)
41+
}
42+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2023 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.apkupdate
7+
8+
import android.app.PendingIntent
9+
import android.content.Context
10+
import android.content.Intent
11+
import android.content.pm.PackageInstaller
12+
import android.os.Build
13+
import org.signal.core.util.PendingIntentFlags
14+
import org.signal.core.util.StreamUtil
15+
import org.signal.core.util.getDownloadManager
16+
import org.signal.core.util.logging.Log
17+
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
18+
import org.thoughtcrime.securesms.keyvalue.SignalStore
19+
import org.thoughtcrime.securesms.util.FileUtils
20+
import java.io.FileInputStream
21+
import java.io.IOException
22+
import java.io.InputStream
23+
import java.security.MessageDigest
24+
25+
object ApkUpdateInstaller {
26+
27+
private val TAG = Log.tag(ApkUpdateInstaller::class.java)
28+
29+
/**
30+
* Installs the downloaded APK silently if possible. If not, prompts the user with a notification to install.
31+
* May show errors instead under certain conditions.
32+
*
33+
* A common pattern you may see is that this is called with [userInitiated] = false (or some other state
34+
* that prevents us from auto-updating, like the app being in the foreground), causing this function
35+
* to show an install prompt notification. The user clicks that notification, calling this with
36+
* [userInitiated] = true, and then everything installs.
37+
*/
38+
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
39+
if (downloadId != SignalStore.apkUpdate().downloadId) {
40+
Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.")
41+
return
42+
}
43+
44+
val digest = SignalStore.apkUpdate().digest
45+
if (digest == null) {
46+
Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.")
47+
SignalStore.apkUpdate().clearDownloadAttributes()
48+
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
49+
return
50+
}
51+
52+
if (!isMatchingDigest(context, downloadId, digest)) {
53+
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
54+
SignalStore.apkUpdate().clearDownloadAttributes()
55+
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
56+
return
57+
}
58+
59+
if (!userInitiated && !shouldAutoUpdate()) {
60+
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})")
61+
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
62+
return
63+
}
64+
65+
try {
66+
installApk(context, downloadId, userInitiated)
67+
} catch (e: IOException) {
68+
Log.w(TAG, "Hit IOException when trying to install APK!", e)
69+
SignalStore.apkUpdate().clearDownloadAttributes()
70+
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
71+
} catch (e: SecurityException) {
72+
Log.w(TAG, "Hit SecurityException when trying to install APK!", e)
73+
SignalStore.apkUpdate().clearDownloadAttributes()
74+
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
75+
}
76+
}
77+
78+
@Throws(IOException::class, SecurityException::class)
79+
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
80+
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
81+
if (apkInputStream == null) {
82+
Log.w(TAG, "Could not open download APK input stream!")
83+
return
84+
}
85+
86+
Log.d(TAG, "Beginning APK install...")
87+
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
88+
89+
Log.d(TAG, "Clearing inactive sessions...")
90+
packageInstaller.mySessions
91+
.filter { session -> !session.isActive }
92+
.forEach { session ->
93+
try {
94+
packageInstaller.abandonSession(session.sessionId)
95+
} catch (e: SecurityException) {
96+
Log.w(TAG, "Failed to abandon inactive session!", e)
97+
}
98+
}
99+
100+
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
101+
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
102+
// This lets us skip the system-generated notification.
103+
if (Build.VERSION.SDK_INT >= 31) {
104+
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
105+
}
106+
}
107+
108+
Log.d(TAG, "Creating install session...")
109+
val sessionId: Int = packageInstaller.createSession(sessionParams)
110+
val session: PackageInstaller.Session = packageInstaller.openSession(sessionId)
111+
112+
Log.d(TAG, "Writing APK data...")
113+
session.use { activeSession ->
114+
val sessionOutputStream = activeSession.openWrite(context.packageName, 0, -1)
115+
StreamUtil.copy(apkInputStream, sessionOutputStream)
116+
}
117+
118+
val installerPendingIntent = PendingIntent.getBroadcast(
119+
context,
120+
sessionId,
121+
Intent(context, ApkUpdatePackageInstallerReceiver::class.java).apply {
122+
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_USER_INITIATED, userInitiated)
123+
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_DOWNLOAD_ID, downloadId)
124+
},
125+
PendingIntentFlags.mutable() or PendingIntentFlags.updateCurrent()
126+
)
127+
128+
Log.d(TAG, "Committing session...")
129+
session.commit(installerPendingIntent.intentSender)
130+
}
131+
132+
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
133+
return try {
134+
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
135+
} catch (e: IOException) {
136+
Log.w(TAG, e)
137+
null
138+
}
139+
}
140+
141+
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
142+
return try {
143+
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
144+
val digest = FileUtils.getFileDigest(stream)
145+
MessageDigest.isEqual(digest, expectedDigest)
146+
}
147+
} catch (e: IOException) {
148+
Log.w(TAG, e)
149+
false
150+
}
151+
}
152+
153+
private fun shouldAutoUpdate(): Boolean {
154+
// TODO Auto-updates temporarily disabled. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
155+
return false
156+
// return Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
157+
}
158+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2023 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.apkupdate
7+
8+
import android.content.BroadcastReceiver
9+
import android.content.Context
10+
import android.content.Intent
11+
import org.signal.core.util.logging.Log
12+
13+
/**
14+
* Receiver that is triggered based on various notification actions that can be taken on update-related notifications.
15+
*/
16+
class ApkUpdateNotificationReceiver : BroadcastReceiver() {
17+
18+
companion object {
19+
private val TAG = Log.tag(ApkUpdateNotificationReceiver::class.java)
20+
21+
const val ACTION_INITIATE_INSTALL = "signal.apk_update_notification.initiate_install"
22+
const val EXTRA_DOWNLOAD_ID = "signal.download_id"
23+
}
24+
25+
override fun onReceive(context: Context, intent: Intent?) {
26+
if (intent == null) {
27+
Log.w(TAG, "Null intent")
28+
return
29+
}
30+
31+
val downloadId: Long = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2)
32+
33+
when (val action: String? = intent.action) {
34+
ACTION_INITIATE_INSTALL -> handleInstall(context, downloadId)
35+
else -> Log.w(TAG, "Unrecognized notification action: $action")
36+
}
37+
}
38+
39+
private fun handleInstall(context: Context, downloadId: Long) {
40+
Log.i(TAG, "Got action to install.")
41+
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = true)
42+
}
43+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2023 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.apkupdate
7+
8+
import android.annotation.SuppressLint
9+
import android.app.PendingIntent
10+
import android.content.Context
11+
import android.content.Intent
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.content.ContextCompat
14+
import org.signal.core.util.PendingIntentFlags
15+
import org.signal.core.util.logging.Log
16+
import org.thoughtcrime.securesms.MainActivity
17+
import org.thoughtcrime.securesms.R
18+
import org.thoughtcrime.securesms.notifications.NotificationChannels
19+
import org.thoughtcrime.securesms.notifications.NotificationIds
20+
import org.thoughtcrime.securesms.util.ServiceUtil
21+
22+
object ApkUpdateNotifications {
23+
24+
val TAG = Log.tag(ApkUpdateNotifications::class.java)
25+
26+
/**
27+
* 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.
28+
* Note: This is an 'ongoing' notification (i.e. not-user dismissable) and never dismissed programatically. This is because the act of installing the APK
29+
* will dismiss it for us.
30+
*/
31+
@SuppressLint("LaunchActivityFromNotification")
32+
fun showInstallPrompt(context: Context, downloadId: Long) {
33+
val pendingIntent = PendingIntent.getBroadcast(
34+
context,
35+
1,
36+
Intent(context, ApkUpdateNotificationReceiver::class.java).apply {
37+
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
38+
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
39+
},
40+
PendingIntentFlags.immutable()
41+
)
42+
43+
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
44+
.setOngoing(true)
45+
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_prompt_install_title))
46+
.setContentText(context.getString(R.string.ApkUpdateNotifications_prompt_install_body))
47+
.setSmallIcon(R.drawable.ic_notification)
48+
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
49+
.setContentIntent(pendingIntent)
50+
.build()
51+
52+
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
53+
}
54+
55+
fun showInstallFailed(context: Context, reason: FailureReason) {
56+
val pendingIntent = PendingIntent.getActivity(
57+
context,
58+
0,
59+
Intent(context, MainActivity::class.java),
60+
PendingIntentFlags.immutable()
61+
)
62+
63+
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
64+
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_failed_general_title))
65+
.setContentText(context.getString(R.string.ApkUpdateNotifications_failed_general_body))
66+
.setSmallIcon(R.drawable.ic_notification)
67+
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
68+
.setContentIntent(pendingIntent)
69+
.build()
70+
71+
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
72+
}
73+
74+
enum class FailureReason {
75+
UNKNOWN,
76+
ABORTED,
77+
BLOCKED,
78+
INCOMPATIBLE,
79+
INVALID,
80+
CONFLICT,
81+
STORAGE,
82+
TIMEOUT
83+
}
84+
}

0 commit comments

Comments
 (0)