Skip to content

Commit 0752dc2

Browse files
nqmgamingclaude
andcommitted
fix(install): stage APKs before the grant dies, and let the prompt alert
Two faults in the notification install path, both found on device. The install failed with "Permission Denial: opening provider ru.zdevs.zarchiver.provider.FileProvider ... that is not exported". The staging comment claimed parsing leaves cached copies in pendingApkUris — that is only true for archives, which get extracted. A plain APK keeps the caller's own URI, whose read grant dies with the activity that received the intent, so the install read it too late and was denied. Copies are now made explicitly while the grant is alive, exposed through our own FileProvider so every install backend can open them, and swept after an hour so an unanswered prompt does not cost disk forever. A partial copy returns null rather than installing an incomplete split set. The prompt also sat silently in the shade instead of arriving. Its channel was IMPORTANCE_DEFAULT, which only earns a shade row — a notification that asks a question has to interrupt. Now HIGH, under a new channel id because Android ignores importance changes to a channel that already exists. Verified on device: the notification arrives with Install / Cancel, and Install now reaches the system confirmation with no SecurityException. Heads-up is still suppressed while Do Not Disturb is on, which is the device's call to make, not ours. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 08657eb commit 0752dc2

2 files changed

Lines changed: 66 additions & 5 deletions

File tree

app/src/main/java/app/pwhs/universalinstaller/presentation/install/InstallPromptNotifier.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import java.io.File
3030
* - **Cancel** is a broadcast to [InstallActionReceiver], which needs no window at all.
3131
*
3232
* The channel is deliberately its own: this is the only install notification that expects an
33-
* answer, so it takes DEFAULT importance while progress stays quiet on LOW.
33+
* answer, so it takes HIGH importance and arrives as a heads-up, while progress stays quiet on LOW.
3434
*/
3535
class InstallPromptNotifier(
3636
private val context: Context,
@@ -66,8 +66,12 @@ class InstallPromptNotifier(
6666
.setContentTitle(title)
6767
.setContentText(body)
6868
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
69+
// A question needs to arrive, not wait in the shade to be discovered. HIGH + a
70+
// full-screen-less heads-up is what makes it pop over the current app; DEFAULT only
71+
// added a shade row, which meant swiping down to find it.
6972
.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
70-
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
73+
.setPriority(NotificationCompat.PRIORITY_HIGH)
74+
.setDefaults(NotificationCompat.DEFAULT_ALL)
7175
.setOnlyAlertOnce(true)
7276
.setAutoCancel(false)
7377
// A prompt nobody answers must not linger as a dead button: swiping it away is
@@ -147,7 +151,7 @@ class InstallPromptNotifier(
147151
val channel = NotificationChannel(
148152
CHANNEL_ID,
149153
context.getString(R.string.install_prompt_channel_name),
150-
NotificationManager.IMPORTANCE_DEFAULT,
154+
NotificationManager.IMPORTANCE_HIGH,
151155
).apply {
152156
description = context.getString(R.string.install_prompt_channel_desc)
153157
setShowBadge(true)
@@ -157,7 +161,11 @@ class InstallPromptNotifier(
157161
}
158162

159163
private companion object {
160-
const val CHANNEL_ID = "install_prompt"
164+
/**
165+
* Versioned: Android ignores importance changes to a channel that already exists, so the
166+
* v1 channel would stay on DEFAULT for anyone who had already received a prompt.
167+
*/
168+
const val CHANNEL_ID = "install_prompt_v2"
161169
const val NOTIF_ID_BASE = 43000
162170
}
163171
}

app/src/main/java/app/pwhs/universalinstaller/presentation/install/InstallViewModel.kt

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,8 +571,14 @@ class InstallViewModel(
571571
if (uris.isNullOrEmpty()) return null
572572
val fileName = pendingFileName ?: return null
573573

574+
// Copy now, while the grant is still alive. What the parse leaves in pendingApkUris is
575+
// the caller's own URI for a plain APK (only archives get extracted into our cache), and
576+
// that URI dies with this activity — installing from it later fails with
577+
// "Permission Denial: opening provider <their>.FileProvider ... not exported".
578+
val installableUris = copyForLaterInstall(uris) ?: return null
579+
574580
val entry = PendingInstallStore.put(
575-
apkUris = uris,
581+
apkUris = installableUris,
576582
originalUri = pendingOriginalUri,
577583
fileName = fileName,
578584
packageName = apkInfo.packageName,
@@ -593,6 +599,47 @@ class InstallViewModel(
593599
return entry
594600
}
595601

602+
/**
603+
* Duplicate [uris] into our own cache and hand back FileProvider URIs for the copies.
604+
*
605+
* Needed because a pending install outlives the activity that received the intent, and with
606+
* it the one-shot read grant on the source. Copies go through our own provider rather than
607+
* `file://` so every install backend sees a URI it can open.
608+
*
609+
* @return null if any copy fails — a partial split set would install as a broken app.
610+
*/
611+
private suspend fun copyForLaterInstall(uris: List<Uri>): List<Uri>? = withContext(Dispatchers.IO) {
612+
runCatching {
613+
val dir = File(application.cacheDir, "$PENDING_INSTALL_DIR/${System.currentTimeMillis()}")
614+
if (!dir.mkdirs() && !dir.isDirectory) error("Could not create $dir")
615+
prunePendingInstallCache()
616+
uris.mapIndexed { index, uri ->
617+
val target = File(dir, "$index.apk")
618+
application.contentResolver.openInputStream(uri)?.use { input ->
619+
target.outputStream().use { output -> input.copyTo(output) }
620+
} ?: error("Could not read $uri")
621+
FileProvider.getUriForFile(
622+
application,
623+
"${application.packageName}.fileprovider",
624+
target,
625+
)
626+
}
627+
}.onFailure { Timber.e(it, "Could not stage APKs for a pending install") }.getOrNull()
628+
}
629+
630+
/**
631+
* Drop staged copies older than [PENDING_INSTALL_TTL_MS]. They are only needed until the
632+
* notification is answered, and a prompt nobody answers should not cost disk forever.
633+
*/
634+
private fun prunePendingInstallCache() {
635+
val root = File(application.cacheDir, PENDING_INSTALL_DIR)
636+
val cutoff = System.currentTimeMillis() - PENDING_INSTALL_TTL_MS
637+
root.listFiles()?.forEach { dir ->
638+
val stamp = dir.name.toLongOrNull() ?: return@forEach
639+
if (stamp < cutoff) dir.deleteRecursively()
640+
}
641+
}
642+
596643
/**
597644
* Put a stashed parse back so [confirmInstall] — and the dialog, if it is being shown — see
598645
* exactly the state that produced the notification. No re-parse, so split selection and OBB
@@ -1258,6 +1305,12 @@ class InstallViewModel(
12581305
/** User-facing folder under /sdcard/Download/ so downloads are easy to browse. */
12591306
const val DOWNLOADS_SUBFOLDER = "UniversalInstaller"
12601307

1308+
/** Cache subfolder holding APKs staged for a notification the user has not answered. */
1309+
private const val PENDING_INSTALL_DIR = "pending_install"
1310+
1311+
/** How long a staged copy is kept before being swept. */
1312+
private const val PENDING_INSTALL_TTL_MS = 60 * 60 * 1000L
1313+
12611314
private val ABI_TOKENS = setOf(
12621315
"armeabi_v7a", "arm64_v8a", "x86_64", "armeabi", "x86", "mips64", "mips",
12631316
)

0 commit comments

Comments
 (0)