Skip to content

Commit ad9d083

Browse files
nqmgamingclaude
andcommitted
fix(install): tell read failures, parse failures and missing permission apart
Chasing the first gap against InstallerX Revived, which has separate states for "could not read the file" and "could not parse the package" where this app had one. It turned out to have less than one. An unreadable URI crashed the app outright. getDisplayName queries the sending app's provider from composition, on the main thread, with no guard — so the moment that grant lapsed it threw SecurityException and took the process down: FATAL EXCEPTION: main java.lang.SecurityException: Permission Denial: opening provider ru.zdevs.zarchiver.provider.FileProvider ... not exported from UID 10373 at ContentResolverKt.getDisplayName(ContentResolver.kt:12) at DialogInstallActivity.onCreate$lambda$2(DialogInstallActivity.kt:468) It is best-effort now: no name is not a reason to lose the install. Parse failures did not crash but were barely better — a toast saying "unsupported file" and the dialog vanishing, regardless of whether the file was unreadable or genuinely not a package. Both are dialog states now: ReadFailed says the sending app no longer allows access and to open it again from there; ParseFailed says the bytes arrived but are not an installable package. The underlying exception is shown as detail rather than as the whole message. Which one you get is decided by the throwable — SecurityException, FileNotFoundException and IOException mean the bytes never arrived. Third state, the second gap: PermissionRequired. Install-from-unknown-sources is now checked when Install is tapped, so a missing permission asks for it instead of starting an install that fails inside the system installer and gets reported as if the package were at fault. The crash fix is verified by the stack trace above. The three states compile but are not yet seen on device — the phone dropped off adb before this build could be installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 74d9c2b commit ad9d083

7 files changed

Lines changed: 232 additions & 26 deletions

File tree

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

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
88
import android.net.Uri
99
import android.os.Build
1010
import android.os.Bundle
11+
import android.provider.Settings
1112
import android.widget.Toast
1213
import androidx.core.content.ContextCompat
1314
import androidx.activity.ComponentActivity
@@ -85,6 +86,7 @@ import app.pwhs.universalinstaller.domain.model.InstallUiStyle
8586
import app.pwhs.universalinstaller.presentation.setting.PreferencesKeys
8687
import app.pwhs.universalinstaller.presentation.setting.SecurityLevel
8788
import app.pwhs.core.data.local.dataStore
89+
import app.pwhs.core.util.PermissionMonitor
8890
import app.pwhs.universalinstaller.presentation.install.dialog.DialogFailedContent
8991
import app.pwhs.universalinstaller.presentation.install.dialog.DialogInstallingContent
9092
import app.pwhs.universalinstaller.presentation.install.dialog.DialogMenuContent
@@ -108,6 +110,8 @@ import ru.solrudev.ackpine.splits.ApkSplits.validate
108110
import ru.solrudev.ackpine.splits.SplitPackage.Companion.toSplitPackage
109111
import ru.solrudev.ackpine.splits.ZippedApkSplits
110112
import timber.log.Timber
113+
import java.io.FileNotFoundException
114+
import java.io.IOException
111115
import app.pwhs.core.domain.ThemeMode
112116
import app.pwhs.core.domain.AppThemePreset
113117
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -165,8 +169,8 @@ class DialogInstallActivity : ComponentActivity() {
165169
*/
166170
private const val LOG = "NotifInstall"
167171

168-
/** Rounded at the top only — the bottom edge runs off the screen. */
169-
private val SHEET_SHAPE = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
172+
/** All four corners: the sheet floats inside the window rather than meeting its edge. */
173+
private val FLOATING_SHEET_SHAPE = RoundedCornerShape(28.dp)
170174
}
171175

172176
// POST_NOTIFICATIONS gates the background-install notification on Android 13+. We ask
@@ -187,6 +191,36 @@ class DialogInstallActivity : ComponentActivity() {
187191
/** Track whether system took us to a confirmation activity. */
188192
private var wentToSystemConfirm = false
189193

194+
/**
195+
* Decide which of the two failures the user is looking at.
196+
*
197+
* A SecurityException or a missing file means the bytes never arrived — the sending app's
198+
* grant expired, or the file moved. Anything else got past reading and fell over on the
199+
* package itself. Blaming the package for the first case is what the old single message did.
200+
*/
201+
private fun reportParseProblem(cause: Throwable) {
202+
val unreadable = cause is SecurityException ||
203+
cause is FileNotFoundException ||
204+
cause is IOException
205+
val detail = cause.message.orEmpty()
206+
if (unreadable) viewModel.dialogReadFailed(detail) else viewModel.dialogParseFailed(detail)
207+
}
208+
209+
/** Whether Android will let us install at all. False means the install would fail instantly. */
210+
private fun canInstallPackages(): Boolean =
211+
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
212+
packageManager.canRequestPackageInstalls()
213+
214+
private fun openInstallPermissionSettings() {
215+
PermissionMonitor.start(this) { packageManager.canRequestPackageInstalls() }
216+
startActivity(
217+
Intent(
218+
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
219+
Uri.parse("package:$packageName"),
220+
)
221+
)
222+
}
223+
190224
/**
191225
* The parts that make a sheet a sheet rather than a lowered dialog: a drag handle to grab, and
192226
* content kept clear of the gesture bar so the action row is not sitting on the system inset.
@@ -204,7 +238,6 @@ class DialogInstallActivity : ComponentActivity() {
204238
Column(
205239
modifier = Modifier
206240
.fillMaxWidth()
207-
.navigationBarsPadding()
208241
.padding(bottom = 8.dp),
209242
horizontalAlignment = Alignment.CenterHorizontally,
210243
) {
@@ -449,12 +482,10 @@ class DialogInstallActivity : ComponentActivity() {
449482
if (skipInitialParse) return@LaunchedEffect
450483
runCatching { parseAndPush(context, incomingUri) }.onFailure { e ->
451484
Timber.e(e, "Parse failed for $incomingUri")
452-
Toast.makeText(
453-
context,
454-
resource.getString(R.string.install_unsupported_file),
455-
Toast.LENGTH_LONG,
456-
).show()
457-
finish()
485+
// Was a toast and finish(): the dialog vanished and the user was told
486+
// "unsupported file" even when the real problem was that we never got to
487+
// read it. Show which of the two it was, and stay on screen to say so.
488+
reportParseProblem(e)
458489
}
459490
}
460491

@@ -515,10 +546,13 @@ class DialogInstallActivity : ComponentActivity() {
515546
val handleInstallTap = {
516547
val info = uiState.pendingApkInfo
517548
val risks = if (info != null) detectInstallRisks(info, strictVirusTotalCheck) else emptyList()
518-
if (risks.isNotEmpty()) {
519-
pendingRisks = risks
520-
} else {
521-
proceedInstall()
549+
when {
550+
// Checked before anything else: without this the install starts, fails
551+
// somewhere inside the system installer, and the failure gets reported as if
552+
// the package were at fault.
553+
!canInstallPackages() -> viewModel.dialogPermissionRequired()
554+
risks.isNotEmpty() -> pendingRisks = risks
555+
else -> proceedInstall()
522556
}
523557
}
524558

@@ -635,8 +669,14 @@ class DialogInstallActivity : ComponentActivity() {
635669
SheetEntryAnimation(enabled = isSheet) {
636670
Surface(
637671
modifier = (if (isSheet) {
672+
// Floating rather than flush: inset from the edges and rounded on all
673+
// four corners, the way InstallerX Revived's miuix sheet sits. A sheet
674+
// glued to the bottom edge leaves two square corners against the
675+
// gesture bar, which is what prompted this.
638676
Modifier
639677
.fillMaxWidth()
678+
.navigationBarsPadding()
679+
.padding(horizontal = 12.dp, vertical = 12.dp)
640680
.heightIn(max = screenHeight * 0.9f)
641681
} else {
642682
Modifier
@@ -650,7 +690,7 @@ class DialogInstallActivity : ComponentActivity() {
650690
// A sheet is attached to the edge, not floating over the screen: it takes
651691
// the sheet container colour and no drop shadow. Lowering a dialog card to
652692
// the bottom without this still reads as a dialog.
653-
shape = if (isSheet) BottomSheetDefaults.ExpandedShape else AlertDialogDefaults.shape,
693+
shape = if (isSheet) FLOATING_SHEET_SHAPE else AlertDialogDefaults.shape,
654694
color = if (isSheet) BottomSheetDefaults.ContainerColor else AlertDialogDefaults.containerColor,
655695
tonalElevation = if (isSheet) BottomSheetDefaults.Elevation else AlertDialogDefaults.TonalElevation,
656696
shadowElevation = if (isSheet) 0.dp else 12.dp,
@@ -671,6 +711,7 @@ class DialogInstallActivity : ComponentActivity() {
671711
onMenu = viewModel::dialogShowMenu,
672712
onUnblock = viewModel::unblockPackage,
673713
onMenuBack = viewModel::dialogBackToPrepare,
714+
onGrantInstallPermission = { openInstallPermissionSettings() },
674715
onCheckVirusTotal = {
675716
viewModel.scanVirusTotal(this@DialogInstallActivity)
676717
},
@@ -769,11 +810,7 @@ class DialogInstallActivity : ComponentActivity() {
769810
lifecycleScope.launch {
770811
runCatching { parseAndPush(context, uri) }.onFailure { e ->
771812
Timber.e(e, "Parse failed for new intent $uri")
772-
Toast.makeText(
773-
context,
774-
getString(R.string.install_unsupported_file),
775-
Toast.LENGTH_LONG,
776-
).show()
813+
reportParseProblem(e)
777814
}
778815
}
779816
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ sealed interface DialogStage {
2929
/** Install failed — show error + retry/close buttons. */
3030
data class Failed(val errorMessage: String = "") : DialogStage
3131

32+
/**
33+
* The file could not be read at all: the grant died, it was moved, or the source app's
34+
* provider refused us. Distinct from [ParseFailed] because the fix is different — nothing
35+
* about the package is wrong, we just never got its bytes.
36+
*/
37+
data class ReadFailed(val reason: String = "") : DialogStage
38+
39+
/**
40+
* The bytes arrived but are not an installable package — wrong format, truncated download,
41+
* an archive with no APK inside.
42+
*/
43+
data class ParseFailed(val reason: String = "") : DialogStage
44+
45+
/**
46+
* Installing from unknown sources is not granted, so the install would fail the moment it
47+
* started. Asks for the permission instead of failing and blaming the package.
48+
*/
49+
data object PermissionRequired : DialogStage
50+
3251
/** No dialog should be shown. */
3352
data object None : DialogStage
3453
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,15 @@ class InstallViewModel(
300300
/** Install failed. */
301301
fun dialogInstallFailed(error: String) { _dialogStage.value = DialogStage.Failed(error) }
302302

303+
/** The source could not be read — see [DialogStage.ReadFailed]. */
304+
fun dialogReadFailed(reason: String) { _dialogStage.value = DialogStage.ReadFailed(reason) }
305+
306+
/** The source is not an installable package — see [DialogStage.ParseFailed]. */
307+
fun dialogParseFailed(reason: String) { _dialogStage.value = DialogStage.ParseFailed(reason) }
308+
309+
/** Install-from-unknown-sources is missing — see [DialogStage.PermissionRequired]. */
310+
fun dialogPermissionRequired() { _dialogStage.value = DialogStage.PermissionRequired }
311+
303312
/** Close dialog entirely. */
304313
fun dialogClose() { _dialogStage.value = DialogStage.None }
305314

app/src/main/java/app/pwhs/universalinstaller/presentation/install/dialog/DialogGenerator.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import androidx.compose.ui.unit.dp
2727
import androidx.core.graphics.drawable.toBitmap
2828
import androidx.compose.ui.res.stringResource
2929
import app.pwhs.universalinstaller.R
30+
import androidx.compose.material.icons.rounded.ErrorOutline
31+
import androidx.compose.material.icons.rounded.Security
3032
import app.pwhs.universalinstaller.presentation.install.AttachedObb
3133
import app.pwhs.universalinstaller.presentation.install.DialogStage
3234
import app.pwhs.universalinstaller.presentation.install.DialogTarget
@@ -54,6 +56,7 @@ fun generateDialogParams(
5456
onSelectUserId: (Int?) -> Unit,
5557
onSkipParse: (() -> Unit)? = null,
5658
onFallbackInstall: (() -> Unit)? = null,
59+
onGrantInstallPermission: () -> Unit = {},
5760
): DialogParams {
5861
return when (val stage = uiState.dialogStage) {
5962
DialogStage.Loading -> {
@@ -227,6 +230,43 @@ fun generateDialogParams(
227230
}
228231
}
229232

233+
is DialogStage.ReadFailed -> DialogParams(
234+
content = DialogInnerParams("read_failed") {
235+
DialogProblemContent(
236+
icon = Icons.Rounded.ErrorOutline,
237+
title = stringResource(R.string.dialog_read_failed_title),
238+
explanation = stringResource(R.string.dialog_read_failed_text),
239+
detail = stage.reason,
240+
onClose = onCloseAfterResult,
241+
)
242+
}
243+
)
244+
245+
is DialogStage.ParseFailed -> DialogParams(
246+
content = DialogInnerParams("parse_failed") {
247+
DialogProblemContent(
248+
icon = Icons.Rounded.ErrorOutline,
249+
title = stringResource(R.string.dialog_parse_failed_title),
250+
explanation = stringResource(R.string.dialog_parse_failed_text),
251+
detail = stage.reason,
252+
onClose = onCloseAfterResult,
253+
)
254+
}
255+
)
256+
257+
DialogStage.PermissionRequired -> DialogParams(
258+
content = DialogInnerParams("permission_required") {
259+
DialogProblemContent(
260+
icon = Icons.Rounded.Security,
261+
title = stringResource(R.string.dialog_permission_title),
262+
explanation = stringResource(R.string.dialog_permission_text),
263+
actionLabel = stringResource(R.string.dialog_permission_grant),
264+
onAction = onGrantInstallPermission,
265+
onClose = onCancel,
266+
)
267+
}
268+
)
269+
230270
DialogStage.None -> DialogParams()
231271
}
232272
}

app/src/main/java/app/pwhs/universalinstaller/presentation/install/dialog/DialogInstallStages.kt

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,92 @@ fun DialogFailedContent(
389389
}
390390
}
391391
}
392+
393+
/**
394+
* A problem that stopped the install before it started: the file could not be read, it is not a
395+
* package, or the permission to install is missing.
396+
*
397+
* Separate from [DialogFailedContent], which reports an install session that ran and failed. The
398+
* distinction matters to the person reading it — "we never got your file" and "Android refused
399+
* the install" call for different actions, and the old code collapsed both into one message (or,
400+
* for read and parse errors, into a toast and a vanished dialog).
401+
*/
402+
@Composable
403+
fun DialogProblemContent(
404+
icon: androidx.compose.ui.graphics.vector.ImageVector,
405+
title: String,
406+
explanation: String,
407+
detail: String? = null,
408+
actionLabel: String? = null,
409+
onAction: (() -> Unit)? = null,
410+
onClose: () -> Unit,
411+
) {
412+
Column(
413+
modifier = Modifier
414+
.fillMaxWidth()
415+
.padding(24.dp),
416+
horizontalAlignment = Alignment.CenterHorizontally,
417+
) {
418+
Icon(
419+
imageVector = icon,
420+
contentDescription = null,
421+
tint = MaterialTheme.colorScheme.error,
422+
modifier = Modifier.size(56.dp),
423+
)
424+
425+
Spacer(modifier = Modifier.height(16.dp))
426+
427+
Text(
428+
text = title,
429+
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
430+
textAlign = TextAlign.Center,
431+
)
432+
433+
Spacer(modifier = Modifier.height(8.dp))
434+
435+
Text(
436+
text = explanation,
437+
style = MaterialTheme.typography.bodyMedium,
438+
color = MaterialTheme.colorScheme.onSurfaceVariant,
439+
textAlign = TextAlign.Center,
440+
)
441+
442+
// The raw cause, kept out of the main sentence: useful when reporting a bug, noise
443+
// otherwise. Scrollable because provider exceptions run long.
444+
if (!detail.isNullOrBlank()) {
445+
Spacer(modifier = Modifier.height(12.dp))
446+
Column(
447+
modifier = Modifier
448+
.fillMaxWidth()
449+
.heightIn(max = 120.dp)
450+
.verticalScroll(rememberScrollState()),
451+
) {
452+
Text(
453+
text = detail,
454+
style = MaterialTheme.typography.bodySmall,
455+
color = MaterialTheme.colorScheme.onSurfaceVariant,
456+
textAlign = TextAlign.Center,
457+
)
458+
}
459+
}
460+
461+
Spacer(modifier = Modifier.height(20.dp))
462+
463+
if (onAction != null && actionLabel != null) {
464+
Button(onClick = onAction, modifier = Modifier.fillMaxWidth()) {
465+
Text(actionLabel)
466+
}
467+
Spacer(modifier = Modifier.height(8.dp))
468+
androidx.compose.material3.OutlinedButton(
469+
onClick = onClose,
470+
modifier = Modifier.fillMaxWidth(),
471+
) {
472+
Text(stringResource(R.string.dialog_failed_close))
473+
}
474+
} else {
475+
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) {
476+
Text(stringResource(R.string.dialog_failed_close))
477+
}
478+
}
479+
}
480+
}

app/src/main/java/app/pwhs/universalinstaller/util/extension/ContentResolver.kt

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import android.net.Uri
55
import android.provider.OpenableColumns
66
import androidx.core.net.toFile
77

8-
fun ContentResolver.getDisplayName(uri: Uri): String {
8+
/**
9+
* Best-effort display name for [uri], or "" when it cannot be read.
10+
*
11+
* Querying another app's provider throws SecurityException the moment its grant lapses, and this
12+
* runs on the main thread from composition — an unreadable URI used to take the whole app down
13+
* with it rather than surface as an error the user could read.
14+
*/
15+
fun ContentResolver.getDisplayName(uri: Uri): String = runCatching {
916
if (uri.scheme == ContentResolver.SCHEME_FILE) {
10-
return uri.toFile().name
17+
return@runCatching uri.toFile().name
1118
}
1219
query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null).use { cursor ->
13-
if (cursor == null || !cursor.moveToFirst()) {
14-
return ""
15-
}
16-
return cursor.getString(0)
20+
if (cursor == null || !cursor.moveToFirst()) "" else cursor.getString(0).orEmpty()
1721
}
18-
}
22+
}.getOrDefault("")

app/src/main/res/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@
9797
<string name="fab_install">Install Package</string>
9898
<string name="uninstall">Uninstall</string>
9999
<!-- Install mode / confirm-from-notification -->
100+
<!-- Problems that stop an install before it starts -->
101+
<string name="dialog_read_failed_title">Couldn\'t read the file</string>
102+
<string name="dialog_read_failed_text">The app that sent this package no longer allows access to it. Open the file again from that app.</string>
103+
<string name="dialog_parse_failed_title">Not an installable package</string>
104+
<string name="dialog_parse_failed_text">The file was read, but it isn\'t a valid APK, APKS, XAPK or APKM — it may be incomplete or a different kind of archive.</string>
105+
<string name="dialog_permission_title">Installation not allowed yet</string>
106+
<string name="dialog_permission_text">Android needs your permission for this app to install packages. Nothing has been installed yet.</string>
107+
<string name="dialog_permission_grant">Grant permission</string>
100108
<string name="install_prompt_channel_name">Install confirmation</string>
101109
<string name="install_prompt_channel_desc">Asks whether to install a package you opened, without covering the app you opened it from.</string>
102110
<string name="install_prompt_notif_text">Ready to install. Tap to review, or install straight away.</string>

0 commit comments

Comments
 (0)