Skip to content

Commit 0c98cd1

Browse files
nqmgamingclaude
andcommitted
feat(install): blacklist packages, and fix the dead VirusTotal button
## Blacklist (#101 parity — InstallerX has this, we did not) Packages the user chooses never to install. Deliberately not an InstallRisk: a risk warns and lets you push past it, and a blacklist you can click through is not a blacklist. The gate lives inside confirmInstall — the single funnel every install path reaches, so a new entry point cannot bypass it — and reads a hot StateFlow rather than DataStore, because that runs on the main thread. Surfaced everywhere the state matters, which was the gap in the first attempt: - Manage: a Blocked badge on the row, in error colours next to the neutral System/Split badges, plus a Blocked filter chip. The chips generate from AppFilter.entries so the enum entry was enough. - Install sheet and install dialog: a banner above the footer with the Install button disabled, and Unblock right there. Learning something is blocked happens when you try to install it; making the user hunt through Settings to undo it is the wrong shape. - Settings → Blocked packages: its own screen, not a collapsed section buried between Sync and Advanced. Review-and-remove only — no text field, because nobody types com.example.app from memory and a typo silently blocks nothing. Adding happens in Manage where the user can see what they are blocking. Blocking is a toggle, so the same row unblocks. ## VirusTotal button did nothing without a key The VirusTotal card lives inside ApkInfoContent's scroll area, which only exists when the sheet is expanded. Tapping Scan from the compact sheet set the state and rendered the result into a composable that was not on screen — no key, no result, no error, just a dead button. The button now expands the sheet first. And rather than only reporting "no API key", both the sheet and the dialog offer the fix: Add API key opens Settings, Get a free key opens virustotal.com. Verified on device: the dialog's VirusTotal row shows the no-key message and opens Settings when tapped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b16f04b commit 0c98cd1

19 files changed

Lines changed: 629 additions & 11 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@
215215
android:name=".presentation.setting.help.HelpActivity"
216216
android:exported="false"
217217
android:theme="@style/Theme.UniversalInstaller" />
218+
<activity
219+
android:name=".presentation.setting.blacklist.BlacklistActivity"
220+
android:exported="false"
221+
android:theme="@style/Theme.UniversalInstaller" />
218222

219223
<provider
220224
android:name="rikka.shizuku.ShizukuProvider"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package app.pwhs.universalinstaller.domain.manager
2+
3+
import androidx.datastore.preferences.core.Preferences
4+
import androidx.datastore.preferences.core.stringSetPreferencesKey
5+
6+
/**
7+
* Package names the user has chosen never to install.
8+
*
9+
* Deliberately *not* modelled as an [app.pwhs.universalinstaller.presentation.install.dialog.InstallRisk].
10+
* A risk is something you warn about and let the user push past — the risk dialog's whole shape is
11+
* "here is the problem, Install anyway". A blacklist that can be clicked through is not a
12+
* blacklist. This blocks the install outright and the only way forward is to remove the entry.
13+
*
14+
* The store is a plain `Set<String>` in DataStore rather than a Room table: it is a handful of
15+
* package names, read on every install, and a table would mean a migration for no benefit.
16+
*/
17+
object InstallBlacklist {
18+
19+
val KEY: Preferences.Key<Set<String>> = stringSetPreferencesKey("install_blacklist")
20+
21+
fun read(prefs: Preferences?): Set<String> = prefs?.get(KEY).orEmpty()
22+
23+
/**
24+
* True when [packageName] is blocked.
25+
*
26+
* Blank package names never match. An APK we failed to parse reports its package as blank or
27+
* "Unknown", and blocking every unparseable file because the blacklist happens to be non-empty
28+
* would be a nasty surprise.
29+
*/
30+
fun isBlocked(prefs: Preferences?, packageName: String): Boolean =
31+
packageName.isNotBlank() && packageName in read(prefs)
32+
33+
fun add(current: Set<String>, packageName: String): Set<String> =
34+
if (packageName.isBlank()) current else current + packageName.trim()
35+
36+
fun remove(current: Set<String>, packageName: String): Set<String> =
37+
current - packageName
38+
}

app/src/main/java/app/pwhs/universalinstaller/domain/model/ApkInfo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,6 @@ data class ApkInfo(
4040
* a mismatch. See [app.pwhs.universalinstaller.util.SignatureCheck].
4141
*/
4242
val signatureMismatch: Boolean? = null,
43+
/** The user put this package on the never-install list; the Install button is disabled. */
44+
val isBlocked: Boolean = false,
4345
)

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

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
2727
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
2828
import androidx.compose.material.icons.rounded.Android
2929
import androidx.compose.material.icons.rounded.Badge
30+
import androidx.compose.material.icons.rounded.Block
3031
import androidx.compose.material.icons.rounded.CheckCircle
3132
import androidx.compose.material.icons.rounded.CloudUpload
3233
import androidx.compose.material.icons.rounded.Delete
@@ -139,6 +140,7 @@ internal fun ApkInfoContent(
139140
onToggleAllUsers: (Boolean) -> Unit = {},
140141
onSelectUserId: (Int?) -> Unit = {},
141142
startCompact: Boolean = true,
143+
onUnblock: (String) -> Unit = {},
142144
) {
143145
val context = LocalContext.current
144146
val currentMappingProfileId = appProfileMapping[apkInfo.packageName]
@@ -332,6 +334,15 @@ internal fun ApkInfoContent(
332334
fileSizeBytes = apkInfo.fileSizeBytes,
333335
sha256 = apkInfo.sha256,
334336
onCheck = onCheckVirusTotal,
337+
onOpenSettings = {
338+
context.startActivity(
339+
android.content.Intent(
340+
context,
341+
app.pwhs.universalinstaller.presentation.setting.SettingActivity::class.java,
342+
)
343+
)
344+
},
345+
onGetKey = { uriHandler.openUri("https://www.virustotal.com/gui/my-apikey") },
335346
onOpenLink = {
336347
if (apkInfo.vtResult?.status in setOf(VtStatus.CLEAN, VtStatus.MALICIOUS, VtStatus.SUSPICIOUS) && apkInfo.sha256.isNotBlank()) {
337348
uriHandler.openUri("https://www.virustotal.com/gui/file/${apkInfo.sha256}/detection")
@@ -347,6 +358,40 @@ internal fun ApkInfoContent(
347358
Spacer(Modifier.height(16.dp))
348359
} // end scroll area
349360

361+
// Blocked banner sits above the footer, not in the scroll area: the Install button below
362+
// is disabled and the user needs to see why without scrolling back up.
363+
if (apkInfo.isBlocked) {
364+
Surface(
365+
color = MaterialTheme.colorScheme.errorContainer,
366+
modifier = Modifier.fillMaxWidth(),
367+
) {
368+
Row(
369+
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
370+
verticalAlignment = Alignment.CenterVertically,
371+
) {
372+
Icon(
373+
Icons.Rounded.Block,
374+
contentDescription = null,
375+
tint = MaterialTheme.colorScheme.onErrorContainer,
376+
modifier = Modifier.size(18.dp),
377+
)
378+
Spacer(Modifier.width(10.dp))
379+
Text(
380+
text = stringResource(R.string.install_blocked_banner),
381+
style = MaterialTheme.typography.bodySmall,
382+
color = MaterialTheme.colorScheme.onErrorContainer,
383+
modifier = Modifier.weight(1f),
384+
)
385+
TextButton(onClick = { onUnblock(apkInfo.packageName) }) {
386+
Text(
387+
stringResource(R.string.install_blocked_unblock),
388+
color = MaterialTheme.colorScheme.onErrorContainer,
389+
)
390+
}
391+
}
392+
}
393+
}
394+
350395
// Fixed footer — sits outside the scroll so the action row is always on screen.
351396
// A hairline divider hints there's scrollable content above it when expanded.
352397
if (isExpanded) {
@@ -387,6 +432,7 @@ internal fun ApkInfoContent(
387432
onClick = onInstall,
388433
modifier = Modifier.weight(1f),
389434
shape = MaterialTheme.shapes.medium,
435+
enabled = !apkInfo.isBlocked,
390436
colors = if (isDowngrade) ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) else ButtonDefaults.buttonColors()
391437
) {
392438
if (confirmText == null) {
@@ -397,7 +443,14 @@ internal fun ApkInfoContent(
397443
}
398444
} else {
399445
Button(
400-
onClick = onCheckVirusTotal,
446+
onClick = {
447+
// The VirusTotal card lives inside the scroll area, which only exists
448+
// when expanded. Tapping this from the compact sheet used to change
449+
// state with nothing on screen to show it — no key, no result, no
450+
// error, just a dead button. Expand first so the outcome is visible.
451+
if (!isExpanded) isExpanded = true
452+
onCheckVirusTotal()
453+
},
401454
modifier = Modifier.weight(1f),
402455
shape = MaterialTheme.shapes.medium,
403456
enabled = !isScanning
@@ -418,7 +471,8 @@ internal fun ApkInfoContent(
418471
if (!isScanCompleted) {
419472
TextButton(
420473
onClick = onInstall,
421-
modifier = Modifier.fillMaxWidth()
474+
modifier = Modifier.fillMaxWidth(),
475+
enabled = !apkInfo.isBlocked,
422476
) {
423477
Text(stringResource(R.string.skip_and_install_btn))
424478
}
@@ -530,7 +584,15 @@ private fun AbisCard(abis: List<String>) {
530584

531585
@OptIn(ExperimentalMaterial3Api::class)
532586
@Composable
533-
private fun VirusTotalCard(vt: VtResult?, fileSizeBytes: Long, sha256: String = "", onCheck: () -> Unit, onOpenLink: () -> Unit = {}) {
587+
private fun VirusTotalCard(
588+
vt: VtResult?,
589+
fileSizeBytes: Long,
590+
sha256: String = "",
591+
onCheck: () -> Unit,
592+
onOpenSettings: () -> Unit = {},
593+
onGetKey: () -> Unit = {},
594+
onOpenLink: () -> Unit = {},
595+
) {
534596
val extendedColors = LocalExtendedColors.current
535597
val status = vt?.status
536598
val inProgress = status == VtStatus.SCANNING || status == VtStatus.UPLOADING || status == VtStatus.QUEUED || status == VtStatus.ANALYZING
@@ -574,6 +636,32 @@ private fun VirusTotalCard(vt: VtResult?, fileSizeBytes: Long, sha256: String =
574636
Spacer(Modifier.height(12.dp))
575637
VtBreakdownSection(vt = vt, warningColor = extendedColors.warning)
576638
}
639+
// Telling someone their key is missing is only half an answer — the fix is two
640+
// screens away and they are mid-install. Offer both steps here.
641+
if (status == VtStatus.NO_API_KEY) {
642+
Spacer(Modifier.height(8.dp))
643+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
644+
FilledTonalButton(
645+
onClick = onOpenSettings,
646+
shape = MaterialTheme.shapes.medium,
647+
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
648+
) {
649+
Text(
650+
stringResource(R.string.apk_info_vt_add_key),
651+
style = MaterialTheme.typography.labelMedium,
652+
)
653+
}
654+
TextButton(
655+
onClick = onGetKey,
656+
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
657+
) {
658+
Text(
659+
stringResource(R.string.apk_info_vt_get_key),
660+
style = MaterialTheme.typography.labelMedium,
661+
)
662+
}
663+
}
664+
}
577665
}
578666
}
579667
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ class DialogInstallActivity : ComponentActivity() {
401401
finish()
402402
},
403403
onMenu = viewModel::dialogShowMenu,
404+
onUnblock = viewModel::unblockPackage,
404405
onMenuBack = viewModel::dialogBackToPrepare,
405406
onCheckVirusTotal = {
406407
viewModel.scanVirusTotal(this@DialogInstallActivity)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ fun InstallScreen(
178178
onCancel = viewModel::cancelSession,
179179
onRetry = viewModel::retrySession,
180180
onDismissSession = viewModel::dismissSession,
181+
onUnblock = viewModel::unblockPackage,
181182
onClearHistory = viewModel::clearHistory,
182183
onCheckVirusTotal = { viewModel.scanVirusTotal(context) },
183184
onStartDeviceScan = { viewModel.startDeviceScan(context) },
@@ -249,6 +250,7 @@ private fun InstallUi(
249250
onCancel: (java.util.UUID) -> Unit = {},
250251
onRetry: (java.util.UUID) -> Unit = {},
251252
onDismissSession: (java.util.UUID) -> Unit = {},
253+
onUnblock: (String) -> Unit = {},
252254
onClearHistory: () -> Unit = {},
253255
onCheckVirusTotal: () -> Unit = {},
254256
onStartDeviceScan: () -> Unit = {},
@@ -490,6 +492,7 @@ private fun InstallUi(
490492
onToggleAllUsers = onToggleAllUsers,
491493
onSelectUserId = onSelectUserId,
492494
startCompact = true,
495+
onUnblock = onUnblock,
493496
)
494497
}
495498
}

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import androidx.work.WorkManager
3939
import app.pwhs.universalinstaller.presentation.install.dialog.isDowngrade
4040
import app.pwhs.universalinstaller.util.DhizukuCompat
4141
import app.pwhs.universalinstaller.presentation.install.controller.DhizukuInstallController
42+
import app.pwhs.universalinstaller.domain.manager.InstallBlacklist
4243
import app.pwhs.universalinstaller.util.SignatureCheck
4344
import app.pwhs.universalinstaller.presentation.setting.PreferencesKeys
4445
import app.pwhs.core.util.RootShell
@@ -48,6 +49,7 @@ import kotlinx.coroutines.Dispatchers
4849
import kotlinx.coroutines.Job
4950
import kotlinx.coroutines.flow.MutableStateFlow
5051
import kotlinx.coroutines.flow.SharingStarted
52+
import kotlinx.coroutines.flow.catch
5153
import kotlinx.coroutines.flow.StateFlow
5254
import kotlinx.coroutines.flow.asStateFlow
5355
import kotlinx.coroutines.flow.combine
@@ -119,6 +121,18 @@ class InstallViewModel(
119121
private val _batchDetailUri = MutableStateFlow<android.net.Uri?>(null)
120122
private val _dialogStage = MutableStateFlow<DialogStage>(DialogStage.None)
121123
private val _mergeSplits = MutableStateFlow(false)
124+
/**
125+
* Blocked package names, kept hot so [confirmInstall] can check them without suspending.
126+
* Empty on a read failure — failing open beats making every install impossible.
127+
*/
128+
private val blacklist: StateFlow<Set<String>> = application.dataStore.data
129+
.map { InstallBlacklist.read(it) }
130+
.catch { e ->
131+
Timber.w(e, "Could not read the install blacklist")
132+
emit(emptySet())
133+
}
134+
.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
135+
122136
private val _selectedProfileId = MutableStateFlow<String?>(null)
123137

124138
/**
@@ -404,6 +418,21 @@ class InstallViewModel(
404418
).show()
405419
return
406420
}
421+
// Blacklist gate. Deliberately inside confirmInstall rather than at each caller: this is
422+
// the single funnel every install path reaches, so a new entry point cannot bypass it.
423+
// Reads the cached set, not DataStore — this runs on the main thread.
424+
val blockedPackage = apkInfo?.packageName.orEmpty()
425+
if (blockedPackage.isNotBlank() && blockedPackage in blacklist.value) {
426+
Timber.w("Install of $blockedPackage blocked by the user's blacklist")
427+
android.widget.Toast.makeText(
428+
application,
429+
application.getString(R.string.install_blocked_by_blacklist, blockedPackage),
430+
android.widget.Toast.LENGTH_LONG,
431+
).show()
432+
dismissPendingInstall()
433+
return
434+
}
435+
407436
val fn = pendingFileName ?: return
408437
val originalUri = pendingOriginalUri
409438
val obbEntries = pendingObbEntries
@@ -1547,6 +1576,24 @@ class InstallViewModel(
15471576
}
15481577
}
15491578

1579+
/**
1580+
* Take a package off the never-install list from the install screen itself.
1581+
*
1582+
* Reachable here as well as in Settings on purpose: the moment the user learns something is
1583+
* blocked is when they try to install it, and making them hunt through Settings to undo a
1584+
* decision they are questioning right now is the wrong shape.
1585+
*/
1586+
fun unblockPackage(packageName: String) {
1587+
if (packageName.isBlank()) return
1588+
viewModelScope.launch {
1589+
application.dataStore.edit { p ->
1590+
p[InstallBlacklist.KEY] = InstallBlacklist.remove(InstallBlacklist.read(p), packageName)
1591+
}
1592+
// Repaint the sheet that is on screen right now.
1593+
_pendingApkInfo.value = _pendingApkInfo.value?.copy(isBlocked = false)
1594+
}
1595+
}
1596+
15501597
fun dismissSession(id: UUID) {
15511598
viewModelScope.launch {
15521599
activeController().dismiss(id)
@@ -2131,6 +2178,7 @@ class InstallViewModel(
21312178
supportedAbis = supportedAbis.distinct(),
21322179
splitEntries = splitEntries,
21332180
signatureMismatch = signatureMismatch,
2181+
isBlocked = ackpinePackageName in blacklist.value,
21342182
)
21352183
}
21362184

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ fun generateDialogParams(
4040
onInstall: () -> Unit,
4141
onCancel: () -> Unit,
4242
onMenu: () -> Unit,
43+
onUnblock: (String) -> Unit = {},
4344
onMenuBack: () -> Unit,
4445
onCheckVirusTotal: () -> Unit,
4546
onRemoveObb: (AttachedObb) -> Unit,
@@ -122,6 +123,7 @@ fun generateDialogParams(
122123
onInstall = onInstall,
123124
onMenu = onMenu,
124125
onCancel = onCancel,
126+
onUnblock = onUnblock,
125127
)
126128
}
127129
)

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -565,12 +565,23 @@ private fun androidx.compose.foundation.lazy.LazyListScope.securityTab(
565565
)
566566
},
567567
onClick = {
568-
if (vtResult?.status in listOf(VtStatus.CLEAN, VtStatus.MALICIOUS, VtStatus.SUSPICIOUS)) {
569-
if (apkInfo.sha256.isNotBlank()) {
570-
uriHandler.openUri("https://www.virustotal.com/gui/file/${apkInfo.sha256}/detection")
568+
when {
569+
vtResult?.status in listOf(VtStatus.CLEAN, VtStatus.MALICIOUS, VtStatus.SUSPICIOUS) -> {
570+
if (apkInfo.sha256.isNotBlank()) {
571+
uriHandler.openUri("https://www.virustotal.com/gui/file/${apkInfo.sha256}/detection")
572+
}
573+
}
574+
// Without a key, tapping Check only rewrites the same "no key" line the user
575+
// is already reading. Send them where the key is entered instead.
576+
vtResult?.status == VtStatus.NO_API_KEY -> {
577+
context.startActivity(
578+
android.content.Intent(
579+
context,
580+
app.pwhs.universalinstaller.presentation.setting.SettingActivity::class.java,
581+
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
582+
)
571583
}
572-
} else {
573-
onCheckVirusTotal()
584+
else -> onCheckVirusTotal()
574585
}
575586
},
576587
)

0 commit comments

Comments
 (0)