From e942820abe6e0fc5d2cd5b922706d55a72c10803 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Fri, 15 May 2026 14:52:25 +0000 Subject: [PATCH 01/14] update .idea gitignore --- .idea/.gitignore | 12 ++++++------ .idea/vcs.xml | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore index bf5f8aea5..4d56b7827 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,6 +1,6 @@ -/compiler.xml -/jarRepositories.xml -/misc.xml -/vcs.xml -/gradle.xml -/gradle.properties +/* +!/codeStyles/ +!/inspectionProfiles/ +/inspectionProfiles/* +!/inspectionProfiles/Project_Default.xml +!/vcs.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..77ec63edf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file From 322bf8e5e19df9380c68675d5c1745e5d4f66efb Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Fri, 15 May 2026 14:54:14 +0000 Subject: [PATCH 02/14] launch main activity when "Apps are up-to-date" notification is tapped --- .../app/grapheneos/apps/autoupdate/UpdateCheckJob.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt index 98d001ea6..d71f599c9 100644 --- a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt +++ b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt @@ -1,8 +1,10 @@ package app.grapheneos.apps.autoupdate import android.app.Notification +import android.app.PendingIntent import android.app.job.JobParameters import android.app.job.JobService +import android.content.Intent import android.text.format.Formatter import android.util.Log import androidx.navigation.NavDeepLinkBuilder @@ -21,6 +23,7 @@ import app.grapheneos.apps.ui.DetailsScreen import app.grapheneos.apps.ui.ErrorDialog import app.grapheneos.apps.ui.MainActivity import app.grapheneos.apps.util.componentName +import app.grapheneos.apps.util.intent import app.grapheneos.apps.util.isAppInstallationAllowed import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -126,7 +129,11 @@ fun showAllUpToDateNotification() { Notifications.builder(Notifications.CH_AUTO_UPDATE_ALL_UP_TO_DATE).run { setSmallIcon(R.drawable.ic_check) setContentTitle(R.string.notif_auto_update_all_up_to_date) - setAutoCancel(false) + setAutoCancel(false) // this notification is a security status indicator + val contentIntent = PendingIntent.getActivity(appContext, 0, + intent().setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE) + setContentIntent(contentIntent) show(Notifications.ID_AUTO_UPDATE_JOB_STATUS) } } From e4c5cf7da7406701df4214b833fdd3d1991eb982 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 09:39:02 +0000 Subject: [PATCH 03/14] prevent RpcProvider failures from crashing the app --- .../main/java/app/grapheneos/apps/RpcProvider.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/RpcProvider.kt b/app/src/main/java/app/grapheneos/apps/RpcProvider.kt index 4a4eddf41..a639ecd9e 100644 --- a/app/src/main/java/app/grapheneos/apps/RpcProvider.kt +++ b/app/src/main/java/app/grapheneos/apps/RpcProvider.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Bundle import android.os.Message import android.os.Messenger +import android.os.RemoteException import android.util.Log import app.grapheneos.apps.autoupdate.AutoUpdatePrefs import app.grapheneos.apps.core.InstallParams @@ -16,18 +17,25 @@ import app.grapheneos.apps.core.PackageState import app.grapheneos.apps.core.startPackageInstall import app.grapheneos.apps.core.pkgManager import app.grapheneos.apps.util.getApplicationInfo +import app.grapheneos.apps.util.getPackageInfoOrNull +import app.grapheneos.apps.util.isSystemPackage import app.grapheneos.apps.util.maybeGetParcelable import app.grapheneos.apps.util.toInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +private const val TAG = "RpcProvider" + class RpcProvider : ContentProvider() { override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { when (method) { "update_package" -> { val callingPackage = callingPackage!! + if (pkgManager.getPackageInfoOrNull(callingPackage)?.isSystemPackage() != true) { + return null + } val pkgName = arg!! val callback = extras?.maybeGetParcelable("callback") CoroutineScope(Dispatchers.Main).launch { @@ -35,10 +43,14 @@ class RpcProvider : ContentProvider() { updatePackage(callingPackage, pkgName) } res.exceptionOrNull()?.let { - Log.e("RpcProvider", method, it) + Log.e(TAG, method, it) } val resBool = res.getOrNull() ?: false - callback?.send(Message().apply { arg1 = resBool.toInt() }) + try { + callback?.send(Message().apply { arg1 = resBool.toInt() }) + } catch (e: RemoteException) { + Log.e(TAG, method, e) + } } return null } From c4a6738797be3a3759c898271dde349dc355a993 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 09:23:47 +0000 Subject: [PATCH 04/14] update targetSdk and compileSdk to 37 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45057cd4b..59e9d5376 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,7 @@ android { } } - compileSdk = 36 + compileSdk = 37 buildToolsVersion = "36.1.0" namespace = "app.grapheneos.apps" @@ -42,7 +42,7 @@ android { defaultConfig { applicationId = "app.grapheneos.apps" minSdk = 31 - targetSdk = 36 + targetSdk = 37 versionCode = 36 versionName = versionCode.toString() From 5a4df27f57bce7ca2c5ee303ed4fb4e18310b2c9 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 09:26:36 +0000 Subject: [PATCH 05/14] update build tools to 37.0.0 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 59e9d5376..3c5b69b8e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,7 +35,7 @@ android { } compileSdk = 37 - buildToolsVersion = "36.1.0" + buildToolsVersion = "37.0.0" namespace = "app.grapheneos.apps" From 56879e9571f61b84e1396e273b41bced908863f8 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 09:28:25 +0000 Subject: [PATCH 06/14] update dependencies --- gradle/libs.versions.toml | 24 +- gradle/verification-metadata.xml | 5215 +++++++++++------------------- 2 files changed, 1976 insertions(+), 3263 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20b4c6cd8..069d70359 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,19 @@ [versions] activityKtx = "1.13.0" -agp = "9.2.0" -appcompat = "1.7.1" +agp = "9.4.1" +appcompat = "1.8.0" bcprovJdk18on = "1.86" -constraintlayout = "2.2.1" -coreKtx = "1.18.0" -fragmentKtx = "1.8.9" -glide = "5.0.7" -kotlin = "2.3.21" +constraintlayout = "2.2.2" +coreKtx = "1.19.1" +fragmentKtx = "1.9.1" +glide = "5.0.9" +kotlin = "2.4.21" kotlinRetry = "2.0.2" -kotlinxCoroutines = "1.10.2" -ksp = "2.3.7" -lifecycle = "2.10.0" -material = "1.13.0" -navigation = "2.9.8" +kotlinxCoroutines = "1.11.0" +ksp = "2.3.12" +lifecycle = "2.11.0" +material = "1.14.0" +navigation = "2.10.2" preferenceKtx = "1.2.1" swiperefreshlayout = "1.2.0" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index d9ac90ca4..84aebe127 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -198,6 +198,20 @@ + + + + + + + + + + + + + + @@ -262,20 +276,6 @@ - - - - - - - - - - - - - - @@ -359,9 +359,18 @@ - - - + + + + + + + + + + + + @@ -408,18 +417,18 @@ - - - + + + - - + + - - + + - - + + @@ -444,18 +453,18 @@ - - - + + + - - + + - - + + - - + + @@ -533,27 +542,10 @@ - - - - - - - - - - - - - - - - - @@ -568,6 +560,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -582,40 +602,31 @@ - - - - - - - - - + + + - - - - - - + + + - - + + - - - - + + - - + + - - + + + + - - + + @@ -679,21 +690,10 @@ - - - - - - - - - - - @@ -701,27 +701,24 @@ - - - + + + - - + + - - + + - - + + - - - @@ -729,6 +726,20 @@ + + + + + + + + + + + + + + @@ -797,6 +808,20 @@ + + + + + + + + + + + + + + @@ -804,9 +829,6 @@ - - - @@ -814,6 +836,23 @@ + + + + + + + + + + + + + + + + + @@ -927,9 +966,6 @@ - - - @@ -937,6 +973,20 @@ + + + + + + + + + + + + + + @@ -1004,44 +1054,55 @@ - - - - - + + + + + + + + + + + + + + + + - + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -1217,18 +1278,18 @@ - - - + + + - - + + - - + + - - + + @@ -1253,18 +1314,18 @@ - - - + + + - - + + - - + + - - + + @@ -1342,6 +1403,20 @@ + + + + + + + + + + + + + + @@ -1408,34 +1483,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1450,18 +1497,18 @@ - - - + + + - - + + - - + + - - + + @@ -1472,27 +1519,24 @@ - - + + - - + + - - + + - - + + - - - @@ -1500,6 +1544,20 @@ + + + + + + + + + + + + + + @@ -1544,18 +1602,18 @@ - - + + - - + + - - + + - - + + @@ -1580,18 +1638,18 @@ - - + + - - + + - - + + - - + + @@ -1627,6 +1685,20 @@ + + + + + + + + + + + + + + @@ -1682,46 +1754,18 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + - + @@ -1738,6 +1782,20 @@ + + + + + + + + + + + + + + @@ -1771,32 +1829,18 @@ - - - - - - - - - - - - - - - - + + - - + + - - + + - + @@ -1814,9 +1858,6 @@ - - - @@ -1824,6 +1865,23 @@ + + + + + + + + + + + + + + + + + @@ -1868,55 +1926,24 @@ - - - - - - - - - - - - - - - - - + + + - - + + - - - - - - - - - - - - - - - - + + - + - - - @@ -1924,6 +1951,20 @@ + + + + + + + + + + + + + + @@ -1957,17 +1998,6 @@ - - - - - - - - - - - @@ -1985,6 +2015,23 @@ + + + + + + + + + + + + + + + + + @@ -2018,38 +2065,21 @@ - - - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - + @@ -2063,149 +2093,149 @@ - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + @@ -2383,23 +2413,6 @@ - - - - - - - - - - - - - - - - - @@ -2417,21 +2430,38 @@ - - - + + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + - + @@ -2467,18 +2497,18 @@ - - + + - - + + - - + + - - + + @@ -2552,9 +2582,6 @@ - - - @@ -2562,6 +2589,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2672,163 +2727,207 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -2845,18 +2944,18 @@ - - - + + + - - + + - - + + - - + + @@ -2873,111 +2972,111 @@ - - - + + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + @@ -2988,89 +3087,89 @@ - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + @@ -3101,595 +3200,268 @@ - - + + - - - - - - - - - - - - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - - - - - - + + - - - - + + - - + + - - + + - - + + - - - - - - - - - - - + + - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - - + + + - - + + - - - - + + - - + + - - + + - - + + + + - - + + - - - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - - - + + - - + + - - + + + + - - + + - - + + - - - - - - + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -3697,11 +3469,6 @@ - - - - - @@ -3716,20 +3483,6 @@ - - - - - - - - - - - - - - @@ -3763,20 +3516,6 @@ - - - - - - - - - - - - - - @@ -3809,9 +3548,6 @@ - - - @@ -3827,11 +3563,6 @@ - - - - - @@ -3852,20 +3583,6 @@ - - - - - - - - - - - - - - @@ -3894,23 +3611,9 @@ - - - - - - - - - - - - - - - - - + + + @@ -3927,35 +3630,35 @@ - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + @@ -3969,21 +3672,21 @@ - - - + + + - - + + - - + + - - + + - - + + @@ -3997,21 +3700,21 @@ - - - + + + - - + + - - + + - - + + - - + + @@ -4028,32 +3731,21 @@ - - - - - - - - - + + + - - + + - - + + - - - - - - - + + - - + + @@ -4081,31 +3773,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4124,9 +3791,6 @@ - - - @@ -4142,23 +3806,18 @@ - - - + + + - - + + - - + + - - - - - - - + + @@ -4171,16 +3830,6 @@ - - - - - - - - - - @@ -4196,9 +3845,9 @@ - - - + + + @@ -4215,20 +3864,6 @@ - - - - - - - - - - - - - - @@ -4254,39 +3889,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4294,27 +3896,10 @@ - - - - - - - - - - - - - - - - - @@ -4322,9 +3907,6 @@ - - - @@ -4332,6 +3914,23 @@ + + + + + + + + + + + + + + + + + @@ -4342,36 +3941,21 @@ - - - - - - - - - - - - - - - - - - - - + + + + + @@ -4394,17 +3978,6 @@ - - - - - - - - - - - @@ -4412,9 +3985,6 @@ - - - @@ -4462,11 +4032,6 @@ - - - - - @@ -4477,16 +4042,6 @@ - - - - - - - - - - @@ -4509,17 +4064,6 @@ - - - - - - - - - - - @@ -4545,42 +4089,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4588,9 +4096,6 @@ - - - @@ -4609,34 +4114,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4647,11 +4124,6 @@ - - - - - @@ -4662,104 +4134,23 @@ - - - - - - - - - - - - - + + + - - - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -4819,880 +4210,259 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + - - + + - - - + + + - - + + + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + + + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + - - + + - - + + - - + + - - - - - - - - - - - - + + + - - - - - - + + + - - + + + + - - + + + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + + + - - + + - - + + - - - + + + - - + + + + - - + + - - + + - - - - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + + + @@ -5728,20 +4498,6 @@ - - - - - - - - - - - - - - @@ -5756,11 +4512,6 @@ - - - - - @@ -5852,6 +4603,11 @@ + + + + + @@ -5910,6 +4666,23 @@ + + + + + + + + + + + + + + + + + @@ -5952,6 +4725,14 @@ + + + + + + + + @@ -6169,6 +4950,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6183,32 +4989,32 @@ - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - + + - - + + @@ -6225,18 +5031,18 @@ - - - + + + - - + + - - + + - - + + @@ -6250,23 +5056,6 @@ - - - - - - - - - - - - - - - - - @@ -6284,66 +5073,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - + + - - - - + + @@ -6356,6 +5094,22 @@ + + + + + + + + + + + + + + + + @@ -6372,82 +5126,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + + + - - + + - - + + - - + + @@ -6503,20 +5196,6 @@ - - - - - - - - - - - - - - @@ -6579,69 +5258,75 @@ - - + + - + - + - + - + - + - + - + - - + + + + + - + - + - + - + - + - + - + - + - + + + + - + - - + + - - + + @@ -6660,18 +5345,18 @@ - - + + - - + + - - + + - - + + @@ -6685,74 +5370,130 @@ - - + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6766,46 +5507,88 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + @@ -6870,69 +5653,75 @@ - - + + - - + + - + - - + + - + - - + + - + - - + + + + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + + + + - - + + - - + + - - + + @@ -6946,18 +5735,18 @@ - - + + - - + + - - + + - - + + @@ -7022,69 +5811,75 @@ - - - + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + @@ -7101,21 +5896,21 @@ - - + + - + - - + + - - + + - - + + @@ -7129,18 +5924,18 @@ - - + + - + - - + + - - + + @@ -7213,12 +6008,12 @@ - - - + + + - - + + @@ -7232,18 +6027,18 @@ - - + + - - + + - - + + - - + + @@ -7257,46 +6052,46 @@ - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - + - - + + - - + + @@ -7327,20 +6122,6 @@ - - - - - - - - - - - - - - @@ -7380,29 +6161,32 @@ - - + + - + - - + + - - + + - - - + + + - - + + + + + - - + + @@ -7526,20 +6310,6 @@ - - - - - - - - - - - - - - @@ -7551,40 +6321,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -7599,6 +6335,23 @@ + + + + + + + + + + + + + + + + + @@ -7688,9 +6441,6 @@ - - - @@ -7721,23 +6471,6 @@ - - - - - - - - - - - - - - - - - @@ -7775,6 +6508,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7795,24 +6562,24 @@ - - - + + + - + - - + + - - + + - - + + - - + + @@ -7835,6 +6602,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -7879,45 +6666,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -7948,34 +6696,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -8048,34 +6768,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -8090,17 +6782,6 @@ - - - - - - - - - - - @@ -8112,6 +6793,17 @@ + + + + + + + + + + + @@ -8123,46 +6815,46 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + @@ -8176,23 +6868,23 @@ - - + + - - + + - - + + - - + + - - - + + + @@ -8222,28 +6914,6 @@ - - - - - - - - - - - - - - - - - - - - - - @@ -8255,38 +6925,32 @@ - - - - - - + + + - - + + - - - - - + + - - - + + + - - + + - - + + - - + + - - + + @@ -8327,14 +6991,6 @@ - - - - - - - - @@ -8354,6 +7010,11 @@ + + + + + @@ -8364,11 +7025,6 @@ - - - - - @@ -8395,6 +7051,17 @@ + + + + + + + + + + + @@ -8429,9 +7096,6 @@ - - - @@ -8439,17 +7103,6 @@ - - - - - - - - - - - @@ -8473,9 +7126,6 @@ - - - @@ -8489,6 +7139,23 @@ + + + + + + + + + + + + + + + + + @@ -8500,23 +7167,6 @@ - - - - - - - - - - - - - - - - - @@ -8617,12 +7267,63 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -8639,6 +7340,23 @@ + + + + + + + + + + + + + + + + + @@ -8688,11 +7406,6 @@ - - - - - From 7c7dd330363dcd36d8ec98a2babdc07b36186e02 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 10:00:05 +0000 Subject: [PATCH 07/14] add ina dependency --- .github/workflows/build.yml | 8 + .github/workflows/validate-gradle-wrapper.yml | 2 + .gitmodules | 4 + .idea/vcs.xml | 3 +- app/build.gradle.kts | 2 + gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 814 ++++++++++++++++++ ina | 1 + settings.gradle.kts | 5 + 9 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 .gitmodules create mode 160000 ina diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0bfe6fc3..dd27f6abf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,11 +8,19 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true - name: Set up JDK 21 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: 21 cache: gradle + - name: Set up Rust + working-directory: ina + run: | + rustup toolchain install + rustup target add aarch64-linux-android x86_64-linux-android + - run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;30.0.16248370" - name: Build with Gradle run: ./gradlew build --no-daemon diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml index d14371014..ab7f1d974 100644 --- a/.github/workflows/validate-gradle-wrapper.yml +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -8,4 +8,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true - uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..689e87f3d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "ina"] + path = ina + url = https://github.com/GrapheneOS/ina + branch = main diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 77ec63edf..ff4abe9cc 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -11,5 +11,6 @@ + - \ No newline at end of file + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c5b69b8e..c937880c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,8 @@ android { } dependencies { + implementation(libs.ina) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 069d70359..5d2f70c4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s bcprov-jdk18on = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bcprovJdk18on" } glide-core = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } glide-ksp = { group = "com.github.bumptech.glide", name = "ksp", version.ref = "glide" } +ina = { module = "app.accrescent.ina:Ina" } kotlin-retry = { group = "com.michael-bull.kotlin-retry", name = "kotlin-retry", version.ref = "kotlinRetry" } kotlin-retry-result = { group = "com.michael-bull.kotlin-retry", name = "kotlin-retry-result", version.ref = "kotlinRetry" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 84aebe127..4e615ab03 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -373,6 +373,20 @@ + + + + + + + + + + + + + + @@ -685,6 +699,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1421,6 +1463,9 @@ + + + @@ -2564,6 +2609,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2578,6 +2678,20 @@ + + + + + + + + + + + + + + @@ -2765,6 +2879,11 @@ + + + + + @@ -3355,6 +3474,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3440,6 +3683,22 @@ + + + + + + + + + + + + + + + + @@ -4380,6 +4639,17 @@ + + + + + + + + + + + @@ -4512,6 +4782,20 @@ + + + + + + + + + + + + + + @@ -4578,6 +4862,11 @@ + + + + + @@ -4608,6 +4897,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5073,6 +5402,11 @@ + + + + + @@ -5084,6 +5418,20 @@ + + + + + + + + + + + + + + @@ -5094,6 +5442,20 @@ + + + + + + + + + + + + + + @@ -5143,6 +5505,25 @@ + + + + + + + + + + + + + + + + + + + @@ -5185,6 +5566,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6094,6 +6613,17 @@ + + + + + + + + + + + @@ -6133,6 +6663,20 @@ + + + + + + + + + + + + + + @@ -6321,10 +6865,24 @@ + + + + + + + + + + + + + + @@ -6430,6 +6988,9 @@ + + + @@ -6666,7 +7227,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6674,6 +7263,20 @@ + + + + + + + + + + + + + + @@ -6682,6 +7285,14 @@ + + + + + + + + @@ -6696,6 +7307,20 @@ + + + + + + + + + + + + + + @@ -6768,6 +7393,20 @@ + + + + + + + + + + + + + + @@ -6783,6 +7422,9 @@ + + + @@ -6794,6 +7436,9 @@ + + + @@ -6914,6 +7559,17 @@ + + + + + + + + + + + @@ -7025,6 +7681,11 @@ + + + + + @@ -7103,6 +7764,17 @@ + + + + + + + + + + + @@ -7167,6 +7839,23 @@ + + + + + + + + + + + + + + + + + @@ -7184,6 +7873,23 @@ + + + + + + + + + + + + + + + + + @@ -7201,11 +7907,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7217,6 +7956,23 @@ + + + + + + + + + + + + + + + + + @@ -7234,6 +7990,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7275,6 +8073,22 @@ + + + + + + + + + + + + + + + + diff --git a/ina b/ina new file mode 160000 index 000000000..d103e674e --- /dev/null +++ b/ina @@ -0,0 +1 @@ +Subproject commit d103e674eee495a735c5203aaa882c88dacd0efb diff --git a/settings.gradle.kts b/settings.gradle.kts index 9bd254f61..1b4c4cabf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,3 +13,8 @@ dependencyResolutionManagement { } rootProject.name = "AppStore" include(":app") +includeBuild("./ina") { + dependencySubstitution { + substitute(module("app.accrescent.ina:Ina")).using(project(":android")) + } +} From 157815a7ef12a6fc33ce4124d35b3e5c2dca3a39 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 12:00:07 +0000 Subject: [PATCH 08/14] simplify makeTemporaryFileDescriptor() by using O_TMPFILE ABI support is now limited by included native library. --- .../java/app/grapheneos/apps/core/Globals.kt | 1 - .../java/app/grapheneos/apps/util/FdUtils.kt | 39 +++++++------------ 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/core/Globals.kt b/app/src/main/java/app/grapheneos/apps/core/Globals.kt index efe458fc4..87d82fa7a 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Globals.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Globals.kt @@ -50,7 +50,6 @@ val localeManager: LocaleManager? = if (Build.VERSION.SDK_INT >= 33) appContext.getSystemService()!! else null val filesDir: File = appContext.filesDir -val fileForTemporaryFileDescriptor = File(filesDir, "tmp_fd") val cacheDir: File = appContext.cacheDir // limit the number of concurrent downloads diff --git a/app/src/main/java/app/grapheneos/apps/util/FdUtils.kt b/app/src/main/java/app/grapheneos/apps/util/FdUtils.kt index 602bebaaf..a85570aac 100644 --- a/app/src/main/java/app/grapheneos/apps/util/FdUtils.kt +++ b/app/src/main/java/app/grapheneos/apps/util/FdUtils.kt @@ -1,49 +1,36 @@ package app.grapheneos.apps.util +import android.os.Build import android.os.ParcelFileDescriptor import android.system.ErrnoException -import android.system.Int64Ref import android.system.Os import android.system.OsConstants -import android.system.OsConstants.EINVAL -import android.system.OsConstants.O_CREAT +import android.system.OsConstants.O_EXCL import android.system.OsConstants.O_RDWR -import android.system.OsConstants.O_TRUNC import android.system.OsConstants.S_IRUSR import android.system.OsConstants.S_IWUSR import android.util.Log -import app.grapheneos.apps.core.fileForTemporaryFileDescriptor -import java.io.File +import app.grapheneos.apps.core.filesDir import java.io.FileDescriptor private const val TAG = "FdUtils" +// O_TMPFILE flag is not a part of Android SDK, though it is part of Android NDK +private val O_TMPFILE = when (Build.SUPPORTED_64_BIT_ABIS[0]) { + "arm64-v8a" -> 0x404000 + "x86_64" -> 0x410000 + else -> throw IllegalStateException("unsupported ABI") +} + // Returns a file descriptor that is backed by device storage, but not by any file. // Its contents will be discarded automatically when it's closed, or when app process dies. // This allows to avoid creating temporary files that would needlessly take up storage space if -// app process is killed, OS crashes etc, and removes the problem of allocating unique temp file names +// app process is killed, OS crashes etc., and removes the problem of allocating unique temp file names @Throws(ErrnoException::class) fun makeTemporaryFileDescriptor(): ScopedFileDescriptor { - val fd: FileDescriptor - val flags = O_RDWR or O_TRUNC or O_CREAT + val flags = O_RDWR or O_TMPFILE or O_EXCL val mode = S_IRUSR or S_IWUSR - - val file: File = fileForTemporaryFileDescriptor - val path = file.path - - // O_TMPFILE flag is not a part of Android SDK, though it is part of Android NDK. Its value is - // ABI-dependent. Approximate O_TMPFILE with open() + unlink() - synchronized(file) { - fd = Os.open(path, flags, mode) - // there's no wrapper for unlink() - if (!file.delete()) { - Os.close(fd) - // should never happen - throw ErrnoException("makeTemporaryFileDescriptor", EINVAL) - } - } - - return ScopedFileDescriptor(fd) + return ScopedFileDescriptor(Os.open(filesDir.path, flags, mode)) } @Throws(ErrnoException::class) From df955a0eae68cb061b8595ed02620dfc37ceb6cb Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 12:02:22 +0000 Subject: [PATCH 09/14] add support for delta updates and switch from gzip to brotli This commit also adds size and hash checks for APK signatures. --- .../apps/PackageDownloadFgService.kt | 2 +- .../apps/autoupdate/UpdateCheckJob.kt | 3 +- .../java/app/grapheneos/apps/core/Errors.kt | 4 + .../java/app/grapheneos/apps/core/Globals.kt | 2 + .../app/grapheneos/apps/core/InstallTask.kt | 253 +++++++++++++----- .../app/grapheneos/apps/core/PackageState.kt | 54 +++- .../app/grapheneos/apps/core/PackageStates.kt | 2 +- .../java/app/grapheneos/apps/core/Repo.kt | 129 ++++++++- .../app/grapheneos/apps/core/RepoRetriever.kt | 12 +- .../java/app/grapheneos/apps/util/Utils.kt | 57 ++-- app/src/main/res/values/strings.xml | 1 + 11 files changed, 425 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt b/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt index becc76294..3e42108e5 100644 --- a/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt +++ b/app/src/main/java/app/grapheneos/apps/PackageDownloadFgService.kt @@ -36,7 +36,7 @@ class PackageDownloadFgService : Service() { } val progress = downloading.sumOf { it.downloadProgress.get() } - val total = downloading.sumOf { it.downloadTotal } + val total = downloading.sumOf { it.downloadTotal.get() } val remaining = total - progress val remainingStr = Formatter.formatShortFileSize(appContext, remaining) diff --git a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt index d71f599c9..879a92721 100644 --- a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt +++ b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt @@ -87,7 +87,8 @@ class UpdateCheckJob : JobService() { val config = appResources.configuration val sumSize = rPackages.sumOf { - it.collectNeededApks(config).sumOf { it.compressedSize } + val state = PackageStates.getPackageState(it.packageName) + it.calculateDownloadSize(state, it.collectNeededApks(config)) }.let { Formatter.formatShortFileSize(appContext, it) } diff --git a/app/src/main/java/app/grapheneos/apps/core/Errors.kt b/app/src/main/java/app/grapheneos/apps/core/Errors.kt index f7ab9f883..082c3c1f3 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Errors.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Errors.kt @@ -128,6 +128,8 @@ class RepoUpdateError(val throwable: Throwable, val wasUpdateManuallyRequested: } } +class InvalidDeltaBaseApkException(msg: String?) : Exception(msg) + // All errors that happen before the installer session is committed are considered to be download // errors, "download" means "download into PackageInstaller session", not just "download from the // network" @@ -142,6 +144,8 @@ class DownloadError(val pkgLabels: List, val throwable: Throwable) : Err is PackagesBusyException -> return appResources.getQuantityString(R.plurals.download_error_packages_busy, throwable.packageNames.size) + is InvalidDeltaBaseApkException -> + return appResources.getString(R.string.download_error_delta_base_apk_invalid) } return null } diff --git a/app/src/main/java/app/grapheneos/apps/core/Globals.kt b/app/src/main/java/app/grapheneos/apps/core/Globals.kt index 87d82fa7a..7910193f7 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Globals.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Globals.kt @@ -54,3 +54,5 @@ val cacheDir: File = appContext.cacheDir // limit the number of concurrent downloads val httpDownloadSemaphore = Semaphore(3) +// limit ina memory use +val inaPatcherSemaphore = Semaphore(5) diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt b/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt index 3b556a678..50e072ca2 100644 --- a/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt +++ b/app/src/main/java/app/grapheneos/apps/core/InstallTask.kt @@ -19,10 +19,12 @@ import android.system.OsConstants.SEEK_CUR import android.system.OsConstants.S_IRUSR import android.system.OsConstants.S_IWUSR import android.util.Log +import app.accrescent.ina.Ina import app.grapheneos.apps.PackageStates import app.grapheneos.apps.core.InstallerSessions.abandonSession import app.grapheneos.apps.util.ScopedFileDescriptor import app.grapheneos.apps.util.checkMainThread +import app.grapheneos.apps.util.checkSha256 import app.grapheneos.apps.util.copyTo2 import app.grapheneos.apps.util.getPackageArchiveInfo import app.grapheneos.apps.util.getPackageInfoOrNull @@ -39,6 +41,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.job import kotlinx.coroutines.launch @@ -47,15 +50,12 @@ import java.io.File import java.io.FileDescriptor import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException import java.lang.reflect.Method import java.net.HttpURLConnection.HTTP_PARTIAL -import java.security.DigestOutputStream import java.security.GeneralSecurityException -import java.security.MessageDigest import java.util.concurrent.atomic.AtomicLong -import java.util.zip.GZIPInputStream import javax.net.ssl.HttpsURLConnection.HTTP_OK -import kotlin.coroutines.coroutineContext class InstallTask( val rPackage: RPackage, @@ -65,7 +65,10 @@ class InstallTask( val callbackBeforeCommit: (suspend () -> Unit)?, ) { val downloadProgress = AtomicLong() - val downloadTotal = apks.sumOf { it.compressedSize } + val currentVersion = packageState.osPackageInfo?.longVersionCode + val deltaSourceIndex: Int = rPackage.getDeltaSourceIndex(packageState) + val installedSplits: Map = packageState.installedSplits + val downloadTotal = AtomicLong(rPackage.calculateDownloadSize(deltaSourceIndex, installedSplits, apks)) init { checkMainThread() @@ -91,7 +94,7 @@ class InstallTask( suspend fun run(): Deferred { throwIfAppInstallationNotAllowed() - job = coroutineContext.job + job = currentCoroutineContext().job val sessionId = InstallerSessions.createSession(makeSessionParams(), packageState) var shouldAbandonSession = true @@ -140,7 +143,14 @@ class InstallTask( coroutineScope { apks.forEach { apk -> launch { - obtainAndWriteApk(apk, session) + val deltaBaseFd = obtainDeltaBaseFd(apk) + try { + obtainAndWriteApk(apk, session, deltaBaseFd) + } finally { + if (deltaBaseFd != null) { + Os.close(deltaBaseFd) + } + } } } @@ -148,7 +158,8 @@ class InstallTask( apks.forEach { apk -> launch { val name = "${apk.name}.idsig" - obtainAndWriteV4OrFsvSigSignature(name, name, session) + obtainAndWriteV4OrFsvSigSignature(name, name, + apk.v4SignatureSize.toLong(), apk.v4SignatureSha256!!, session) } } } @@ -161,7 +172,8 @@ class InstallTask( val downloadName = "${apk.name}.${certId}.fsv_sig" // OS requires this format for file name that is written into session val name = "${apk.name}.fsv_sig" - obtainAndWriteV4OrFsvSigSignature(downloadName, name, session, + obtainAndWriteV4OrFsvSigSignature(downloadName, name, + apk.fsvSignatureSize.toLong(), apk.fsvSignatureSha256!!, session, // fsv_sig signatures are small (typically under 1K) and don't compress well disableCompression = true) } @@ -200,9 +212,10 @@ class InstallTask( (splitSourceDirs + appInfo.sourceDir).forEach { apkPath -> val apkFile = File(apkPath) launch { + val apkSize = apkFile.length() apkFile.inputStream().use { input -> - session.openWrite(apkFile.name, 0L, apkFile.length()).use { output -> - input.copyTo2(output, job) + session.openWrite(apkFile.name, 0L, apkSize).use { output -> + input.copyTo2(apkSize, output, job, apkFile.path) }} } } @@ -245,43 +258,105 @@ class InstallTask( } } - private suspend fun obtainAndWriteApk(apk: Apk, session: Session) { - val file = File(apksDir, "${apk.name}.gz") + private fun obtainDeltaBaseFd(apk: Apk): FileDescriptor? { + val deltaBasePath: String? = + if (deltaSourceIndex >= 0 && apk.deltaSizes[deltaSourceIndex] > 0) { + installedSplits[apk.name] + } else { + null + } + if (deltaBasePath == null) { + return null + } + var result: FileDescriptor? = null + try { + val fd = try { + Os.open(deltaBasePath, O_RDONLY, 0) + } catch (e: ErrnoException) { + Log.w("obtainDeltaBaseFd", "unable to open $deltaBasePath", e) + return null + } + + try { + if (checkSha256(fd, Os.fstat(fd).st_size, apk.deltasSrcSha256[deltaSourceIndex])) { + lseekToStart(fd) + result = fd + } else { + if (params.isUserInitiated) { + mainHandler.post { + packageState.ignoreDeltasForVersion(currentVersion!!) + } + throw InvalidDeltaBaseApkException("SHA-256 check failed for $deltaBasePath") + } else { + Log.w("obtainDeltaBaseFd", "SHA-256 check failed for $deltaBasePath, falling back to full APK download") + } + } + } finally { + if (result !== fd) { + Os.close(fd) + } + } + } finally { + if (result == null) { + // download will fall back to the full compressed APK + downloadTotal.addAndGet(apk.compressedSize - apk.deltaSizes[deltaSourceIndex]) + } + } + return result + } + + private suspend fun obtainAndWriteApk(apk: Apk, session: Session, deltaBaseFd: FileDescriptor?) { + val fileName = StringBuilder(apk.name).run { + append('.') + if (deltaBaseFd != null) { + append(rPackage.availableDeltaVersions[deltaSourceIndex]) + append(".ina") + } else { + append("br") + } + toString() + } + val file = File(apksDir, fileName) val path = file.path val tmpPath = "$path.tmp" + val fullSize = if (deltaBaseFd != null) apk.deltaSizes[deltaSourceIndex] else apk.compressedSize // Check whether this apk is already partially or fully downloaded try { Os.open(path, O_RDONLY, 0) - } catch (e: ErrnoException) { + } catch (_: ErrnoException) { null }?.let { ScopedFileDescriptor(it).use { fd -> - val curSize = Os.fstat(fd.v).st_size - val fullSize = apk.compressedSize + var curSize = Os.fstat(fd.v).st_size // apk is already fully downloaded if (curSize == fullSize) { downloadProgress.getAndAdd(curSize) - uncompressAndWriteApk(fd.v, apk, session) + uncompressAndWriteApk(fd.v, path, fullSize, apk, deltaBaseFd, session) return } - check(curSize < fullSize) { "unexpected size of $path: $curSize, max expected ${fullSize}" } + if (curSize > fullSize) { + Log.e("obtainAndWriteApk", "unexpected size of $path: $curSize, max expected $fullSize") + curSize = 0 + } // Copy incomplete apk into a tmp file. Could have renamed apk file into tmp file directly, // but this would make things more complicated and brittle, because rename and write // to file may get reordered before they are actually written to storage, which // could corrupt file's contents openTempFileFd(tmpPath).use { tmpFd -> - sendfile(tmpFd.v, fd.v, curSize) - check(Os.lseek(tmpFd.v, 0L, SEEK_CUR) == curSize) - downloadProgress.getAndAdd(curSize) + if (curSize > 0) { + sendfile(tmpFd.v, fd.v, curSize) + check(Os.lseek(tmpFd.v, 0L, SEEK_CUR) == curSize) + downloadProgress.getAndAdd(curSize) + } try { - download(apk.downloadUrl(), tmpFd.v, curSize, fullSize) + download(apk.downloadUrl(fileName), tmpFd.v, curSize, fullSize) } finally { fsyncAndRename(tmpFd.v, tmpPath, path) } - uncompressAndWriteApk(tmpFd.v, apk, session) + uncompressAndWriteApk(tmpFd.v, path, fullSize, apk, deltaBaseFd, session) } } return @@ -290,40 +365,44 @@ class InstallTask( // cached apk not found openTempFileFd(tmpPath).use { tmpFd -> try { - download(apk.downloadUrl(), tmpFd.v, curSize = 0L, apk.compressedSize) + download(apk.downloadUrl(fileName), tmpFd.v, curSize = 0L, fullSize) } finally { fsyncAndRename(tmpFd.v, tmpPath, path) } - uncompressAndWriteApk(tmpFd.v, apk, session) + uncompressAndWriteApk(tmpFd.v, path, fullSize, apk, deltaBaseFd, session) } } - private suspend fun obtainAndWriteV4OrFsvSigSignature(downloadName: String, name: String, session: Session, disableCompression: Boolean = false) { + private suspend fun obtainAndWriteV4OrFsvSigSignature(downloadName: String, name: String, size: Long, sha256: ByteArray, session: Session, disableCompression: Boolean = false) { + val logTag = "obtainApkSignature" val file = File(apksDir, downloadName) val path = file.path // v4 and fsv_sig signatures are usually small, skip download resume support for simplicity - // v4 and fsv_sig signatures don't have SHA-256 checksums: - // - v4 signatures are verified against the corresponding APKs by the OS before installation - // - for fsv_sig signatures, kernel enforces that they are valid and come from a trusted - // certificate that is stored in the immutable system image - // Check whether this signature is already downloaded try { Os.open(path, O_RDONLY, 0) - } catch (e: ErrnoException) { + } catch (_: ErrnoException) { null }?.let { + var written = false ScopedFileDescriptor(it).use { fd -> - val fileSize = Os.fstat(fd.v).st_size - - FileInputStream(fd.v).use { inputStream -> - session.openWrite(name, 0, fileSize).use { outputStream -> - inputStream.copyTo2(outputStream, job) - }} + if (!checkSha256(fd.v, size, sha256)) { + Log.e(logTag, "sha256 mismatch for cached $path") + } else { + lseekToStart(fd.v) + FileInputStream(fd.v).use { inputStream -> + session.openWrite(name, 0, size).use { outputStream -> + inputStream.copyTo2(size, outputStream, job, path) + written = true + } + } + } + } + if (written) { + return } - return } val tmpPath = "$path.tmp" @@ -346,39 +425,87 @@ class InstallTask( conn.v.inputStream.use { inputStream -> FileOutputStream(tmpFd.v).use { outputStream -> - inputStream.copyTo2(outputStream, job, downloadProgress) + inputStream.copyTo2(size, outputStream, job, url, downloadProgress) }} } } - fsyncAndRename(tmpFd.v, tmpPath, path) - - val fileSize = downloadProgress.get() lseekToStart(tmpFd.v) + if (!checkSha256(tmpFd.v, size, sha256)) { + throw GeneralSecurityException("sha256 mismatch for $path") + } + lseekToStart(tmpFd.v) + + fsyncAndRename(tmpFd.v, tmpPath, path) FileInputStream(tmpFd.v).use { inputStream -> - session.openWrite(name, 0, fileSize).use { outputStream -> - inputStream.copyTo2(outputStream, job) - }} + session.openWrite(name, 0, size).use { outputStream -> + inputStream.copyTo2(size, outputStream, job, name) + } + } } } - private fun uncompressAndWriteApk(compressedFd: FileDescriptor, apk: Apk, session: Session) { - lseekToStart(compressedFd) + // file descriptor arguments are never closed by this function + @Throws(IOException::class) + private suspend fun inaPatch(oldFileFd: FileDescriptor, patch: FileDescriptor, new: FileDescriptor): Long { + return inaPatcherSemaphore.withPermit { + Ina.patch(oldFileFd, patch, new) + } + } + + private suspend fun uncompressAndWriteApk( + compressedFd: FileDescriptor, + compressedPath: String, + compressedSize: Long, + apk: Apk, + deltaBaseFd: FileDescriptor?, + session: Session + ) { + val compressedSha256: ByteArray = if (deltaBaseFd != null) { + apk.deltasSha256[deltaSourceIndex] + } else { + apk.compressedSha256 + } - val sha256 = MessageDigest.getInstance("SHA-256") + lseekToStart(compressedFd) + if (!checkSha256(compressedFd, compressedSize, compressedSha256)) { + File(compressedPath).delete() + throw GeneralSecurityException("sha256 mismatch for " + + "${if (deltaBaseFd != null) "delta" else "compressed"} file for ${apk.name}") + } + lseekToStart(compressedFd) makeTemporaryFileDescriptor().use { uncompressedFd -> - GZIPInputStream(FileInputStream(compressedFd), DEFAULT_BUFFER_SIZE).use { inputStream -> - DigestOutputStream(FileOutputStream(uncompressedFd.v), sha256).use { outputStream -> - val bytesCopied = inputStream.copyTo2(outputStream, job) - if (bytesCopied != apk.size) { - throw GeneralSecurityException("size mismatch for file ${apk.name}") + if (deltaBaseFd != null) { + try { + val res = inaPatch(deltaBaseFd, compressedFd, uncompressedFd.v) + if (res != apk.size) { + throw GeneralSecurityException("patched size mismatch for ${apk.name}: expected ${apk.size}, got $res") + } + lseekToStart(uncompressedFd.v) + if (!checkSha256(uncompressedFd.v, apk.size, apk.sha256)) { + throw GeneralSecurityException("sha256 mismatch for patched ${apk.name}") + } + } catch (e: Exception) { + when (e) { + is ErrnoException, is GeneralSecurityException, is IOException -> { + mainHandler.post { + packageState.ignoreDeltasForVersion(currentVersion!!) + } + } + } + throw e + } + } else { + val res = Ina.unbrotli(compressedFd, uncompressedFd.v) + if (res != apk.size) { + throw GeneralSecurityException("uncompressed size mismatch for ${apk.name}") + } + lseekToStart(uncompressedFd.v) + if (!checkSha256(uncompressedFd.v, apk.size, apk.sha256)) { + throw GeneralSecurityException("sha256 mismatch for uncompressed ${apk.name}") } - }} - - if (!sha256.digest().contentEquals(apk.sha256)) { - throw GeneralSecurityException("sha256 mismatch for file ${apk.name}") } lseekToStart(uncompressedFd.v) @@ -420,7 +547,7 @@ class InstallTask( FileInputStream(uncompressedFd.v).use { inputStream -> session.openWrite(apk.name, 0, apk.size).use { outputStream -> - inputStream.copyTo2(outputStream, job) + inputStream.copyTo2(apk.size, outputStream, job, apk.name) }} } } @@ -441,9 +568,11 @@ class InstallTask( throwResponseCodeException(conn.v) } + val bytesToCopy = fullSize - curSize + conn.v.inputStream.use { input -> FileOutputStream(fd).use { output -> - input.copyTo2(output, job, progress = downloadProgress) + input.copyTo2(bytesToCopy, output, job, url, progress = downloadProgress) }} } } @@ -503,7 +632,7 @@ class InstallTask( throw UnsupportedOperationException("Multi-package sessions aren't supported properly by Android 12 for unprivileged installers") } - val job = coroutineContext.job + val job = currentCoroutineContext().job var shouldAbandonSession = true val parentSessionId = InstallerSessions.createMultiPackageSession() @@ -562,7 +691,7 @@ class InstallTask( try { findPackageMethod = pkgManager.javaClass.getDeclaredMethod("findPackage", String::class.java, java.lang.Long.TYPE, Bundle::class.java) - } catch (ignored: ReflectiveOperationException) {} + } catch (_: ReflectiveOperationException) {} findPackageMethodInited = true } diff --git a/app/src/main/java/app/grapheneos/apps/core/PackageState.kt b/app/src/main/java/app/grapheneos/apps/core/PackageState.kt index 470f9833c..522f92664 100644 --- a/app/src/main/java/app/grapheneos/apps/core/PackageState.kt +++ b/app/src/main/java/app/grapheneos/apps/core/PackageState.kt @@ -5,14 +5,27 @@ import android.content.pm.PackageInfo import android.content.pm.PackageInstaller import android.os.LocaleList import android.text.format.Formatter +import android.util.ArrayMap import app.grapheneos.apps.PackageStates import app.grapheneos.apps.R import app.grapheneos.apps.util.appendRes +import app.grapheneos.apps.util.checkMainThread +import java.util.Collections // Access only from the main thread class PackageState(val pkgName: String, val id: Long) { lateinit var rPackage: RPackage; private set + var installedSplits: Map = emptyMap() var osPackageInfo: PackageInfo? = null + set(value) { + field = value + installedSplits = getSplitsMap(value) + onDeltaStatusChanged() + } + + var ignoreDeltasForVersion: Long? = null + private set + var releaseChannelOverride: ReleaseChannel? = null var pkgSpecificLocales: LocaleList? = null @@ -53,13 +66,48 @@ class PackageState(val pkgName: String, val id: Long) { fun getDownloadSizeUiString(): String { cachedDownloadSize?.let { return it } - val size = rPackage.collectNeededApks(appResources.configuration).sumOf { it.compressedSize } + val size = rPackage.calculateDownloadSize(this, rPackage.collectNeededApks(appResources.configuration)) Formatter.formatShortFileSize(appContext, size).let { cachedDownloadSize = it return it } } + fun ignoreDeltasForVersion(versionCode: Long) { + checkMainThread() + this.ignoreDeltasForVersion = versionCode + onDeltaStatusChanged() + notifyListeners() + } + + fun onDeltaStatusChanged() { + cachedDownloadSize = null + } + + private fun getSplitsMap(pkgInfo: PackageInfo?): Map { + val appInfo = pkgInfo?.applicationInfo ?: return emptyMap() + val baseApkPath = checkNotNull(appInfo.sourceDir) + val baseApkName = "base.apk" + val splitNames = appInfo.splitNames + if (splitNames == null) { + return Collections.singletonMap(baseApkName, baseApkPath) + } + val numSplits = splitNames.size + val splitPaths = requireNotNull(appInfo.splitSourceDirs) + check(splitPaths.size == numSplits) + val res = ArrayMap(numSplits + 1) + res.put(baseApkName, baseApkPath) + val b = StringBuilder() + for (i in 0 until numSplits) { + b.setLength(0) + b.append("split_") + b.append(checkNotNull(splitNames[i])) + b.append(".apk") + check(res.put(b.toString(), checkNotNull(splitPaths[i])) == null) + } + return res + } + private fun appendDots(sb: StringBuilder) { repeat(PackageStates.updateLoopRunnableRunCount and 0b11) { sb.append('.') @@ -113,7 +161,7 @@ class PackageState(val pkgName: String, val id: Long) { } check(taskState == InstallTask.STATE_DOWNLOADING) val progress = installTask.downloadProgress.get() - val total = installTask.downloadTotal + val total = installTask.downloadTotal.get() if (progress == total) { val sb = StringBuilder() sb.appendRes(ctx, R.string.pkg_status_unpacking) @@ -125,7 +173,7 @@ class PackageState(val pkgName: String, val id: Long) { else R.string.pkg_status_downloading val percent = ((progress.toDouble() / total.toDouble()) * 100.0).toInt() return ctx.getString(ref, percent, - Formatter.formatShortFileSize(ctx, installTask.downloadTotal) + Formatter.formatShortFileSize(ctx, total) ) } } diff --git a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt index ccfbf3b3d..bb727cb63 100644 --- a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt +++ b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt @@ -288,7 +288,7 @@ object PackageStates : LifecycleEventObserver { } private fun updateDownloadFgServiceState() { - if (installTasks.sumOf { it.downloadProgress.get() } < installTasks.sumOf { it.downloadTotal }) { + if (installTasks.sumOf { it.downloadProgress.get() } < installTasks.sumOf { it.downloadTotal.get() }) { if (serviceStarted || ActivityUtils.mostRecentResumedActivity() != null) { val notif = PackageDownloadFgService.createNotification(installTasks) if (notif != null) { diff --git a/app/src/main/java/app/grapheneos/apps/core/Repo.kt b/app/src/main/java/app/grapheneos/apps/core/Repo.kt index 610acab3c..6362635ee 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Repo.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Repo.kt @@ -203,7 +203,7 @@ class RPackageContainer(val repo: Repo, val packageName: String, // This allows to significantly simplify the dependency resolution process (otherwise release // channel could be changed for each of them independently) val group: RPackageGroup? = run { - val groupName = json.opt("group") as String? ?: return@run null + val groupName = json.opt("group") as? String ?: return@run null val group = repo.groups.getOrPut(groupName) { RPackageGroup(groupName) } @@ -302,6 +302,14 @@ private fun parseDependencies(json: JSONObject, repo: Repo): Array? } } +private val emptyByteArray = ByteArray(0) +private val emptyLongArray = LongArray(0) +private val emptyJsonArray = JSONArray() + +private fun JSONObject.optJSONArray2(name: String): JSONArray { + return opt(name) as? JSONArray ?: emptyJsonArray +} + // "Repo package" class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: Array?, repo: Repo, json: JSONObject) { val packageName: String = common.packageName @@ -319,29 +327,76 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A val releaseChannel = ReleaseChannel.valueOf(json.optString("channel", ReleaseChannel.stable.name)) + val availableDeltaVersions: LongArray = + jsonArrayToLongArray(json.optJSONArray2("availableDeltaVersions")) + val apks: List = run { val names = json.getJSONArray("apks") val hashes = json.getJSONArray("apkHashes") val sizes = json.getJSONArray("apkSizes") - val gzSizes = json.getJSONArray("apkGzSizes") + val brSizes = json.getJSONArray("apkBrSizes") + val brHashes = json.getJSONArray("apkBrHashes") + val v4SignatureSizes = json.optJSONArray("v4SignatureSizes") + val v4SignatureHashes = json.optJSONArray("v4SignatureHashes") + val fsvSignatureSizes = if (Build.VERSION.SDK_INT < 35) json.optJSONArray("fsvSignatureSizes") else null + val fsvSignatureHashes = if (Build.VERSION.SDK_INT < 35) json.optJSONArray("fsvSignatureHashes") else null + val allDeltaSrcHashes = json.optJSONArray2("apkDeltaSrcHashes") + val allDeltaSizes = json.optJSONArray2("apkDeltaSizes") + val allDeltaHashes = json.optJSONArray2("apkDeltaHashes") + val numDeltas = availableDeltaVersions.size + var deltasOffset = 0 val len = names.length() + require(numDeltas * len == allDeltaSizes.length()) + require(allDeltaSizes.length() == allDeltaHashes.length()) + require(allDeltaSizes.length() == allDeltaSrcHashes.length()) require(hashes.length() == len) require(sizes.length() == len) - require(gzSizes.length() == len) + require(brSizes.length() == len) + require(brHashes.length() == len) val list = ArrayList(len) for (i in 0 until len) { val name = names.getString(i) - val sha256 = hexStringToByteArray(hashes.getString(i)) - require(sha256.size == (256 / 8)) - val apk = Apk(this, name, sha256, sizes.getLong(i), gzSizes.getLong(i)) + val sha256 = sha256FromJsonArr(hashes, i) + val deltaSizes = jsonArrayToLongArray(allDeltaSizes, deltasOffset, deltasOffset + numDeltas) + val deltasSrcSha256 = Array(numDeltas) { idx -> + if (deltaSizes[idx] == 0L) { + emptyByteArray + } else { + sha256FromJsonArr(allDeltaSrcHashes, deltasOffset + idx) + } + } + val deltasSha256 = Array(numDeltas) { idx -> + if (deltaSizes[idx] == 0L) { + emptyByteArray + } else { + sha256FromJsonArr(allDeltaHashes, deltasOffset + idx) + } + } + deltasOffset += numDeltas + val apk = Apk( + this, + name, + sha256, + sizes.getLong(i), + brSizes.getLong(i), + sha256FromJsonArr(brHashes, i), + deltasSrcSha256, + deltaSizes, + deltasSha256, + v4SignatureSizes?.getInt(i) ?: 0, + v4SignatureHashes?.let { sha256FromJsonArr(it, i) }, + fsvSignatureSizes?.getInt(i) ?: 0, + fsvSignatureHashes?.let { sha256FromJsonArr(it, i) }, + ) if (apk.type == Apk.Type.ABI && apk.qualifier != deviceAbi.apkSplitQualifier) { continue } list.add(apk) } + check(deltasOffset == allDeltaSizes.length()) list } @@ -354,6 +409,37 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A false } + fun getDeltaSourceIndex(state: PackageState): Int { + val pkgInfo = state.osPackageInfo + if (pkgInfo != null) { + val installedVersion = pkgInfo.longVersionCode + val ignoredVersion = state.ignoreDeltasForVersion + if (ignoredVersion == null || installedVersion != ignoredVersion) { + return availableDeltaVersions.indexOf(installedVersion) + } + } + return -1 + } + + // all apks from the list should be from the apks list for this package + fun calculateDownloadSize(state: PackageState, apks: List): Long { + return calculateDownloadSize(getDeltaSourceIndex(state), state.installedSplits, apks) + } + + fun calculateDownloadSize(deltaSourceIndex: Int, installedSplits: Map, apks: List): Long { + if (deltaSourceIndex >= 0) { + return apks.sumOf { + if (installedSplits.contains(it.name)) { + val deltaSize = it.deltaSizes[deltaSourceIndex] + if (deltaSize > 0) deltaSize else it.compressedSize + } else { + it.compressedSize + } + } + } + return apks.sumOf { it.compressedSize } + } + fun collectNeededApks(config: Configuration): List { checkMainThread() @@ -479,6 +565,14 @@ class Apk( val sha256: ByteArray, val size: Long, val compressedSize: Long, + val compressedSha256: ByteArray, + val deltasSrcSha256: Array, + val deltaSizes: LongArray, + val deltasSha256: Array, + val v4SignatureSize: Int, + val v4SignatureSha256: ByteArray?, + val fsvSignatureSize: Int, + val fsvSignatureSha256: ByteArray?, ) { var qualifier = "" @@ -501,7 +595,7 @@ class Apk( } } - fun downloadUrl() = "$REPO_BASE_URL/packages/${pkg.manifestPackageName}/${pkg.versionCode}/$name.gz" + fun downloadUrl(fileName: String) = "$REPO_BASE_URL/packages/${pkg.manifestPackageName}/${pkg.versionCode}/${fileName}" enum class Type { UNCONDITIONAL, @@ -604,6 +698,15 @@ private fun checkPackageDep(dep: ComplexDependency, dependentManifestPkgName: St return dep.check(pi.longVersionCode) } +private fun sha256FromJsonArr(ja: JSONArray, idx: Int): ByteArray { + return sha256FromString(ja.getString(idx)) +} +private fun sha256FromString(str: String): ByteArray { + val res = hexStringToByteArray(str) + require(res.size == (256 / 8)) + return res +} + private fun hexStringToByteArray(s: String): ByteArray { // each byte takes 2 characters, so length must be even require(s.length.isEven()) @@ -619,6 +722,18 @@ private fun hexStringToByteArray(s: String): ByteArray { return arr } +private fun jsonArrayToLongArray(ja: JSONArray, from: Int = 0, to: Int = ja.length()): LongArray { + val len = to - from + if (len == 0) { + return emptyLongArray + } + val res = LongArray(len) + for (i in 0 until len) { + res[i] = ja.getLong(from + i) + } + return res +} + private fun shouldSkipRenamedPackages(): Boolean { val buildIncremental: Long? = Build.VERSION.INCREMENTAL.toLongOrNull() return buildIncremental != null diff --git a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt index 11242da60..a08c06699 100644 --- a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt +++ b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt @@ -20,7 +20,7 @@ import java.nio.charset.StandardCharsets.UTF_8 import java.security.GeneralSecurityException private const val METADATA_VERSION = 1 -private const val CACHE_FILE_VERSION = 1 +private const val CACHE_FILE_VERSION = 2 private const val MIN_TIMESTAMP = 1_770_000_000L private const val PUBLIC_KEY = BuildConfig.REPO_PUBLIC_KEY @@ -53,7 +53,7 @@ fun fetchRepo(currentRepo: Repo): Repo { throwResponseCodeException(conn.v) } - fetchInner(conn.v, MIN_TIMESTAMP) + fetchInner(conn.v, maxOf(currentRepo.timestamp, MIN_TIMESTAMP)) } } } @@ -110,13 +110,17 @@ fun getCachedRepo(): Repo { val json = dis.readByteArray().toString(UTF_8) check(dis.available() == 0) + if (fileVersion != CACHE_FILE_VERSION) { + return createDummy(JSONObject(json).getLong("time")) + } + return Repo(JSONObject(json), eTag) } // make a dummy repo to remove the need to check for null Repo everywhere -private fun createDummy(): Repo { +private fun createDummy(minTimestamp: Long = MIN_TIMESTAMP): Repo { val jo = JSONObject().apply { - put("time", MIN_TIMESTAMP) + put("time", maxOf(MIN_TIMESTAMP, minTimestamp)) put("packages", JSONObject()) } return Repo(jo, eTag = "", isDummy = true) diff --git a/app/src/main/java/app/grapheneos/apps/util/Utils.kt b/app/src/main/java/app/grapheneos/apps/util/Utils.kt index 801c524dd..15864710d 100644 --- a/app/src/main/java/app/grapheneos/apps/util/Utils.kt +++ b/app/src/main/java/app/grapheneos/apps/util/Utils.kt @@ -2,32 +2,26 @@ package app.grapheneos.apps.util import android.content.Context import android.net.Uri -import android.os.UserManager import android.util.ArrayMap -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.InputMethodManager -import android.widget.TextView +import androidx.annotation.CheckResult import androidx.annotation.StringRes -import androidx.core.content.getSystemService import app.grapheneos.apps.core.appContext import app.grapheneos.apps.core.appResources -import app.grapheneos.apps.core.isPrivilegedInstaller import app.grapheneos.apps.core.mainHandler import app.grapheneos.apps.core.mainThread import app.grapheneos.apps.core.selfPkgName -import app.grapheneos.apps.core.userManager import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import org.json.JSONArray -import java.io.DataInputStream -import java.io.DataOutputStream +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.nio.charset.StandardCharsets.UTF_8 +import java.security.GeneralSecurityException +import java.security.MessageDigest import java.util.concurrent.atomic.AtomicLong -import kotlin.coroutines.coroutineContext fun checkMainThread() { check(Thread.currentThread() === mainThread) @@ -38,16 +32,22 @@ fun checkNotMainThread() { } // a variant of kotlin.io.copyTo that checks for job cancellation and publishes progress updates -fun InputStream.copyTo2(out: OutputStream, job: Job, progress: AtomicLong? = null, bufSize: Int = 16 * 1024): Long { +@Throws(GeneralSecurityException::class, IOException::class) +fun InputStream.copyTo2(numBytes: Long, out: OutputStream, job: Job, debugInputName: String, progress: AtomicLong? = null, bufSize: Int = 16 * 1024) { val buf = ByteArray(bufSize) var total = 0L while (true) { job.ensureActive() val bufLen = this.read(buf) - if (bufLen < 0) { + if (bufLen == -1) { break } + check(bufLen > 0) + + if (total + bufLen > numBytes) { + throw GeneralSecurityException("copyTo2: input returned more data than expected (expected $numBytes bytes); input: $debugInputName") + } job.ensureActive() out.write(buf, 0, bufLen) @@ -55,7 +55,34 @@ fun InputStream.copyTo2(out: OutputStream, job: Job, progress: AtomicLong? = nul progress?.addAndGet(bufLen.toLong()) } - return total + if (total != numBytes) { + throw IOException("copyTo2: input didn't return enough data (expected $numBytes bytes); input: $debugInputName") + } +} + +@CheckResult +fun checkSha256(fd: FileDescriptor, size: Long, required: ByteArray): Boolean { + val sha256 = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + var totalRead = 0L + FileInputStream(fd).use { + while (true) { + val numRead = it.read(buf) + if (numRead == -1) { + if (totalRead != size) { + return false + } + break + } + check(numRead > 0) + totalRead += numRead + if (totalRead > size) { + return false + } + sha256.update(buf, 0, numRead) + } + } + return sha256.digest().contentEquals(required) } fun Boolean.toInt() = if (this) 1 else 0 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8765e0e21..ef44ed627 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,7 @@ this package is being installed in another user one or more of these packages are being installed in another user + delta update failed since the checksum of the installed app does not match the expected value. Retrying the app update will download the full app, which uses more data Unable to connect to the server Connection to the server has timed out From b3680a01099f068b9cce892adc39a7d3daca2c26 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 00:13:55 +0000 Subject: [PATCH 10/14] verify app icon hashes before decoding them with Glide --- .../java/app/grapheneos/apps/core/Repo.kt | 5 +- .../java/app/grapheneos/apps/ui/GlideSetup.kt | 84 +++++++++++++++++++ .../grapheneos/apps/ui/PackageListAdapter.kt | 13 +-- .../java/app/grapheneos/apps/util/Utils.kt | 6 +- 4 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt diff --git a/app/src/main/java/app/grapheneos/apps/core/Repo.kt b/app/src/main/java/app/grapheneos/apps/core/Repo.kt index 6362635ee..c3de0ba86 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Repo.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Repo.kt @@ -198,6 +198,8 @@ class RPackageContainer(val repo: Repo, val packageName: String, null } } + val iconSha256: String? = if (iconUrl != null) json.getString("iconSha256") else null + val iconSize: Int = if (iconUrl != null) json.getInt("iconSize") else 0 // Used for setting release channel for packages that are closely linked together. // This allows to significantly simplify the dependency resolution process (otherwise release @@ -701,7 +703,8 @@ private fun checkPackageDep(dep: ComplexDependency, dependentManifestPkgName: St private fun sha256FromJsonArr(ja: JSONArray, idx: Int): ByteArray { return sha256FromString(ja.getString(idx)) } -private fun sha256FromString(str: String): ByteArray { + +fun sha256FromString(str: String): ByteArray { val res = hexStringToByteArray(str) require(res.size == (256 / 8)) return res diff --git a/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt b/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt new file mode 100644 index 000000000..23edd8e41 --- /dev/null +++ b/app/src/main/java/app/grapheneos/apps/ui/GlideSetup.kt @@ -0,0 +1,84 @@ +package app.grapheneos.apps.ui + +import android.content.Context +import app.grapheneos.apps.core.sha256FromString +import app.grapheneos.apps.util.copyTo2 +import app.grapheneos.apps.util.openConnection +import app.grapheneos.apps.util.throwResponseCodeException +import com.bumptech.glide.Glide +import com.bumptech.glide.Priority +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.signature.ObjectKey +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection.HTTP_OK +import java.nio.ByteBuffer +import java.security.DigestOutputStream +import java.security.GeneralSecurityException +import java.security.MessageDigest + +@GlideModule +class AppGlide : AppGlideModule() { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + registry.append(VerifiedUrl::class.java, ByteBuffer::class.java, VerifiedUrlLoader.Factory()) + } +} + +data class VerifiedUrl(val url: String, val size: Int, val sha256: String) + +class VerifiedUrlFetcher(private val verifiedUrl: VerifiedUrl) : DataFetcher { + + override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { + val result: ByteBuffer + try { + openConnection(null, verifiedUrl.url, {}).use { conn -> + if (conn.v.responseCode != HTTP_OK) { + throwResponseCodeException(conn.v) + } + + val arrayOutputStream = ByteArrayOutputStream(verifiedUrl.size) + + val sha256 = MessageDigest.getInstance("SHA-256") + + conn.v.inputStream.use { inputStream -> + DigestOutputStream(arrayOutputStream, sha256).use { outputStream -> + inputStream.copyTo2(verifiedUrl.size.toLong(), outputStream, null, verifiedUrl.url) + } + } + + if (!sha256.digest().contentEquals(sha256FromString(verifiedUrl.sha256))) { + throw GeneralSecurityException("SHA-256 mismatch for ${verifiedUrl.url}") + } + result = ByteBuffer.wrap(arrayOutputStream.toByteArray()) + } + } catch (e: Exception) { + callback.onLoadFailed(e) + return + } + callback.onDataReady(result) + } + + override fun cleanup() = Unit + override fun cancel() = Unit + override fun getDataClass() = ByteBuffer::class.java + override fun getDataSource() = DataSource.REMOTE +} + +class VerifiedUrlLoader : ModelLoader { + override fun buildLoadData(model: VerifiedUrl, width: Int, height: Int, options: Options) = + ModelLoader.LoadData(ObjectKey(model), VerifiedUrlFetcher(model)) + + override fun handles(model: VerifiedUrl) = true + + class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory) = VerifiedUrlLoader() + override fun teardown() {} + } +} diff --git a/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt b/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt index 357155071..1c39b0244 100644 --- a/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt +++ b/app/src/main/java/app/grapheneos/apps/ui/PackageListAdapter.kt @@ -76,21 +76,24 @@ class PackageListAdapter(val fragment: Fragment) : RecyclerView.Adapter Date: Sat, 17 Oct 2026 09:22:19 +0000 Subject: [PATCH 11/14] bump min repo timestamp --- app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt index a08c06699..fe3095a4b 100644 --- a/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt +++ b/app/src/main/java/app/grapheneos/apps/core/RepoRetriever.kt @@ -22,7 +22,7 @@ import java.security.GeneralSecurityException private const val METADATA_VERSION = 1 private const val CACHE_FILE_VERSION = 2 -private const val MIN_TIMESTAMP = 1_770_000_000L +private const val MIN_TIMESTAMP = 1_792_550_000L private const val PUBLIC_KEY = BuildConfig.REPO_PUBLIC_KEY private const val KEY_VERSION = BuildConfig.REPO_KEY_VERSION From fca0835f507834b7e473ba6a5e293c35661d553d Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Sat, 17 Oct 2026 12:39:34 +0000 Subject: [PATCH 12/14] limit max attempts for repo update retry policy --- .../app/grapheneos/apps/core/PackageStates.kt | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt index bb727cb63..587634e61 100644 --- a/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt +++ b/app/src/main/java/app/grapheneos/apps/core/PackageStates.kt @@ -17,41 +17,43 @@ import androidx.core.util.valueIterator import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import app.grapheneos.apps.core.appContext -import app.grapheneos.apps.core.appResources -import app.grapheneos.apps.core.mainHandler -import app.grapheneos.apps.core.pkgManager import app.grapheneos.apps.core.InstallTask import app.grapheneos.apps.core.InstallerSessions import app.grapheneos.apps.core.InstallerSessions.installerSessionMap -import app.grapheneos.apps.util.ActivityUtils -import app.grapheneos.apps.util.checkMainThread -import app.grapheneos.apps.util.forEachEntry -import app.grapheneos.apps.util.getPackageInfoOrNull -import app.grapheneos.apps.util.getSharedPreferences -import app.grapheneos.apps.util.intent -import app.grapheneos.apps.util.invokeOnCompletionOnMainThread -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import app.grapheneos.apps.core.PackageState import app.grapheneos.apps.core.RPackageContainer import app.grapheneos.apps.core.ReleaseChannel import app.grapheneos.apps.core.Repo import app.grapheneos.apps.core.RepoUpdateError +import app.grapheneos.apps.core.appContext +import app.grapheneos.apps.core.appResources import app.grapheneos.apps.core.fetchRepo import app.grapheneos.apps.core.getCachedRepo +import app.grapheneos.apps.core.mainHandler +import app.grapheneos.apps.core.pkgManager import app.grapheneos.apps.core.prunePackageCache +import app.grapheneos.apps.util.ActivityUtils +import app.grapheneos.apps.util.checkMainThread +import app.grapheneos.apps.util.forEachEntry +import app.grapheneos.apps.util.getPackageInfoOrNull import app.grapheneos.apps.util.getParcelableOrThrow +import app.grapheneos.apps.util.getSharedPreferences +import app.grapheneos.apps.util.intent +import app.grapheneos.apps.util.invokeOnCompletionOnMainThread import app.grapheneos.apps.util.simpleName import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok +import com.github.michaelbull.retry.policy.RetryPolicy import com.github.michaelbull.retry.policy.binaryExponentialBackoff +import com.github.michaelbull.retry.policy.stopAtAttempts import com.github.michaelbull.retry.result.retry import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -186,7 +188,7 @@ object PackageStates : LifecycleEventObserver { } suspend fun requestRepoUpdateRetrying(force: Boolean = false, isManuallyRequested: Boolean = false): RepoUpdateError? { - val (_, err) = retry(binaryExponentialBackoff(1_000, 30_000)) { + val (_, err) = retry(RetryPolicy(binaryExponentialBackoff(1_000, 30_000), stopAtAttempts(5))) { val err = requestRepoUpdate(force, isManuallyRequested) if (err != null) Err(err) else Ok(Unit) } From aca6dd9f600d629e1b6f2e789fa76dea06e2226e Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Tue, 20 Oct 2026 09:46:10 +0000 Subject: [PATCH 13/14] narrow down 'catch (Exception)' blocks --- .../app/grapheneos/apps/core/InstallerSessions.kt | 14 ++++++++++---- app/src/main/java/app/grapheneos/apps/core/Repo.kt | 8 +++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt b/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt index 6f208c0ef..6fc4e3804 100644 --- a/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt +++ b/app/src/main/java/app/grapheneos/apps/core/InstallerSessions.kt @@ -239,14 +239,20 @@ object InstallerSessions { fun abandonSession(sessionId: Int): Boolean { // sessionIds are not reused until OS reboots, no need to synchronize - return try { + try { pkgInstaller.abandonSession(sessionId) - true + return true } catch (e: Exception) { // abandonSession() docs say that it throws only SecurityException, but source code // shows that IllegalStateException might be thrown in some cases too - Log.d(TAG, "", e) - false + when (e) { + is IllegalStateException, is SecurityException -> { + Log.d(TAG, "", e) + return false + } + + else -> throw e + } } } } diff --git a/app/src/main/java/app/grapheneos/apps/core/Repo.kt b/app/src/main/java/app/grapheneos/apps/core/Repo.kt index c3de0ba86..929c43efd 100644 --- a/app/src/main/java/app/grapheneos/apps/core/Repo.kt +++ b/app/src/main/java/app/grapheneos/apps/core/Repo.kt @@ -536,7 +536,13 @@ class RPackage(val common: RPackageContainer, val versionCode: Long, val abis: A // getApplicationLocales() is allowed only if we are currently the // installer-of-record for this package. It also could have been racily // uninstalled, which results in an IllegalArgumentException - LocaleList.getEmptyLocaleList() + when (e) { + is IllegalArgumentException, is SecurityException -> { + LocaleList.getEmptyLocaleList() + } + + else -> throw e + } } pkgState.pkgSpecificLocales = list list From 4111973609120cd028dd6d3ce7139f75440f99c3 Mon Sep 17 00:00:00 2001 From: Dmitry Muhomor Date: Tue, 20 Oct 2026 09:44:06 +0000 Subject: [PATCH 14/14] report app download errors from auto-update job --- .../java/app/grapheneos/apps/Notifications.kt | 1 + .../apps/autoupdate/UpdateCheckJob.kt | 3 +++ .../app/grapheneos/apps/core/InstallStart.kt | 15 +++++++++++---- .../app/grapheneos/apps/ui/ActivityUtils.kt | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/grapheneos/apps/Notifications.kt b/app/src/main/java/app/grapheneos/apps/Notifications.kt index ad008a9fd..b69badfa5 100644 --- a/app/src/main/java/app/grapheneos/apps/Notifications.kt +++ b/app/src/main/java/app/grapheneos/apps/Notifications.kt @@ -19,6 +19,7 @@ import app.grapheneos.apps.util.checkMainThread object Notifications { const val ID_PACKAGE_DOWNLOAD = 1 const val ID_AUTO_UPDATE_JOB_STATUS = 2 + const val ID_AUTO_UPDATE_DOWNLOAD_FAILED = 3 private const val FIRST_DYNAMIC_ID = 1000 diff --git a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt index 879a92721..3c50feb08 100644 --- a/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt +++ b/app/src/main/java/app/grapheneos/apps/autoupdate/UpdateCheckJob.kt @@ -22,6 +22,7 @@ import app.grapheneos.apps.show import app.grapheneos.apps.ui.DetailsScreen import app.grapheneos.apps.ui.ErrorDialog import app.grapheneos.apps.ui.MainActivity +import app.grapheneos.apps.util.ActivityUtils import app.grapheneos.apps.util.componentName import app.grapheneos.apps.util.intent import app.grapheneos.apps.util.isAppInstallationAllowed @@ -127,6 +128,8 @@ fun showAllUpToDateNotification() { return } + ActivityUtils.cancelPendingAction(Notifications.ID_AUTO_UPDATE_DOWNLOAD_FAILED) + Notifications.builder(Notifications.CH_AUTO_UPDATE_ALL_UP_TO_DATE).run { setSmallIcon(R.drawable.ic_check) setContentTitle(R.string.notif_auto_update_all_up_to_date) diff --git a/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt b/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt index 871b279f9..58ef6d8d5 100644 --- a/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt +++ b/app/src/main/java/app/grapheneos/apps/core/InstallStart.kt @@ -165,7 +165,7 @@ private fun startInstallTaskInner(pkgs: List, params: InstallParams, } private fun handleInstallTaskError(tasks: List, throwable: Throwable) { - if (tasks.any { !it.params.isUserInitiated || it.isManuallyCancelled }) { + if (tasks.any { it.isManuallyCancelled }) { return } @@ -182,10 +182,17 @@ private fun handleInstallTaskError(tasks: List, throwable: Throwabl } ?: return val template = DownloadError(tasks.map { it.rPackage.label }, error) - val pendingAction = ErrorDialog.createPendingDialog(template) + val pendingDialog = ErrorDialog.createPendingDialog(template) - ActivityUtils.addPendingAction(pendingAction) { - Notifications.builder(Notifications.CH_PACKAGE_DOWLOAD_FAILED).apply { + val notifChannel = if (tasks.any { !it.params.isUserInitiated }) { + pendingDialog.notificationId = Notifications.ID_AUTO_UPDATE_DOWNLOAD_FAILED + Notifications.CH_AUTO_UPDATE_FAILED + } else { + Notifications.CH_PACKAGE_DOWLOAD_FAILED + } + + ActivityUtils.addPendingAction(pendingDialog) { + Notifications.builder(notifChannel).apply { setSmallIcon(R.drawable.ic_error) setContentTitle(R.string.notif_download_failed) setContentText(tasks.map { it.rPackage.label }.joinToString()) diff --git a/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt b/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt index f53362810..b9fb0a641 100644 --- a/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt +++ b/app/src/main/java/app/grapheneos/apps/ui/ActivityUtils.kt @@ -5,6 +5,7 @@ import android.app.Notification import android.content.Intent import android.os.Bundle import android.service.notification.StatusBarNotification +import android.util.ArraySet import androidx.annotation.IdRes import androidx.core.os.postDelayed import app.grapheneos.apps.Notifications @@ -34,6 +35,8 @@ object ActivityUtils { } } + private val delayedPendingActions = ArraySet() + fun addPendingAction(action: PendingAction, notification: Supplier) { checkMainThread() @@ -46,12 +49,21 @@ object ActivityUtils { // when an OS activity launched by us launches our PendingIntent and immediately finishes. // In this case, notification would be shown and dismissed immediately, which is undesirable. // Delay adding of PendingAction for a bit as a workaround + check(delayedPendingActions.add(action)) mainHandler.postDelayed(300L) { + if (!delayedPendingActions.remove(action)) { + return@postDelayed + } + mostRecentResumedActivity()?.let { action.launch(it) return@postDelayed } + if (action.notificationId != 0) { + pendingActions.removeAll { it.notificationId == action.notificationId } + } + pendingActions.add(action) notification.get().apply { @@ -69,6 +81,13 @@ object ActivityUtils { } } + fun cancelPendingAction(notificationId: Int) { + checkMainThread() + delayedPendingActions.removeIf { it.notificationId == notificationId } + pendingActions.removeAll { it.notificationId == notificationId } + Notifications.cancel(notificationId) + } + // Use onResume/onPause instead of onStart/onStop, because the latter are not executed when // non-full-screen activity is launched by us. Launching another activity in this state (paused // but not stopped) may interfere with that activity (it'd become stopped)