Skip to content

Commit 1ea1896

Browse files
nqmgamingclaude
andcommitted
feat(security): Normal vs Strict, and stop VirusTotal owning the install button
Three problems, one cause: VirusTotal was wired as if everyone wanted it pushed at them. **Scan took the primary button from everyone.** ApkInfoContent gave the primary slot to "Scan with VirusTotal" until a verdict existed. For the many users with no API key that button could never produce one, so Install stayed demoted behind a scan that would only ever report a missing key. Install is primary now unless the user asked otherwise. **One setting, two levels.** The old STRICT_VIRUSTOTAL_CHECK boolean only governed the risk gate; the pushy button was unconditional. Replaced with SecurityLevel: - Normal (default) — Install is the main button, VirusTotal stays available in the details, an unscanned file is not a risk. - Strict — the previous behaviour, unchanged. Existing installs keep what they had: SecurityLevel.from() falls back to the old boolean when the new key has never been written, and the setter keeps both in step. **Offered during onboarding.** Strict changes every install afterwards and is useless without an API key, so the VirusTotal page now asks rather than defaulting silently. The keys live in :core/SharedPrefsKeys because onboarding is there while the install flow that reads them is in :app. Settings replaces the switch with a labelled two-option selector — the old switch said "strict check" without ever saying what normal was. Builds clean; not verified on device — USB dropped before I could run it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c98cd1 commit 1ea1896

13 files changed

Lines changed: 233 additions & 12 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ internal fun ApkInfoContent(
141141
onSelectUserId: (Int?) -> Unit = {},
142142
startCompact: Boolean = true,
143143
onUnblock: (String) -> Unit = {},
144+
/** Strict security level: Scan takes the primary button until a verdict exists. */
145+
strictSecurity: Boolean = false,
144146
) {
145147
val context = LocalContext.current
146148
val currentMappingProfileId = appProfileMapping[apkInfo.packageName]
@@ -397,7 +399,10 @@ internal fun ApkInfoContent(
397399
if (isExpanded) {
398400
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
399401
}
400-
val isScanCompleted = apkInfo.vtResult?.status in setOf(VtStatus.CLEAN, VtStatus.MALICIOUS, VtStatus.SUSPICIOUS)
402+
val hasVerdict = apkInfo.vtResult?.status in setOf(VtStatus.CLEAN, VtStatus.MALICIOUS, VtStatus.SUSPICIOUS)
403+
// Strict makes Scan the primary action until a verdict exists. Normal never does — the
404+
// scan is still reachable from the VirusTotal card in the details.
405+
val isScanCompleted = hasVerdict || !strictSecurity
401406
val isScanning = apkInfo.vtResult?.status in setOf(VtStatus.SCANNING, VtStatus.UPLOADING, VtStatus.QUEUED, VtStatus.ANALYZING)
402407

403408
Column(

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import androidx.compose.ui.window.DialogProperties
7070
import app.pwhs.universalinstaller.IntentHandoff
7171
import app.pwhs.universalinstaller.R
7272
import app.pwhs.universalinstaller.presentation.setting.PreferencesKeys
73+
import app.pwhs.universalinstaller.presentation.setting.SecurityLevel
7374
import app.pwhs.core.data.local.dataStore
7475
import app.pwhs.universalinstaller.presentation.install.dialog.DialogFailedContent
7576
import app.pwhs.universalinstaller.presentation.install.dialog.DialogInstallingContent
@@ -217,7 +218,10 @@ class DialogInstallActivity : ComponentActivity() {
217218
val prefs by context.dataStore.data.collectAsState(initial = null)
218219
val autoOpenAfterInstall = prefs?.get(PreferencesKeys.AUTO_OPEN_AFTER_INSTALL) ?: false
219220
val autoConfirmExternalInstall = prefs?.get(PreferencesKeys.AUTO_CONFIRM_EXTERNAL_INSTALL) ?: false
220-
val strictVirusTotalCheck = prefs?.get(PreferencesKeys.STRICT_VIRUSTOTAL_CHECK) ?: false
221+
val strictVirusTotalCheck = SecurityLevel.from(
222+
stored = prefs?.get(PreferencesKeys.SECURITY_LEVEL),
223+
legacyStrict = prefs?.get(PreferencesKeys.STRICT_VIRUSTOTAL_CHECK) ?: false,
224+
) == SecurityLevel.Strict
221225

222226
// Tracks whether we've actually observed the captured session in the repository.
223227
// The session is added inside controller.install() AFTER createSession() suspends,

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Intent
44
import android.net.Uri
55
import android.widget.Toast
66
import app.pwhs.universalinstaller.presentation.setting.PreferencesKeys
7+
import app.pwhs.universalinstaller.presentation.setting.SecurityLevel
78
import app.pwhs.core.data.local.dataStore
89
import app.pwhs.universalinstaller.util.BiometricGate
910
import kotlinx.coroutines.flow.map
@@ -91,9 +92,14 @@ fun InstallScreen(
9192
}
9293
}.collectAsState(initial = true)
9394

95+
// Strict is the only level that treats an unscanned file as a risk and lets the Scan button
96+
// take the primary slot; Normal keeps VirusTotal available without pushing it.
9497
val strictVirusTotalCheck by remember(context) {
9598
context.dataStore.data.map {
96-
it[PreferencesKeys.STRICT_VIRUSTOTAL_CHECK] ?: false
99+
SecurityLevel.from(
100+
stored = it[PreferencesKeys.SECURITY_LEVEL],
101+
legacyStrict = it[PreferencesKeys.STRICT_VIRUSTOTAL_CHECK] ?: false,
102+
) == SecurityLevel.Strict
97103
}
98104
}.collectAsState(initial = false)
99105

@@ -179,6 +185,7 @@ fun InstallScreen(
179185
onRetry = viewModel::retrySession,
180186
onDismissSession = viewModel::dismissSession,
181187
onUnblock = viewModel::unblockPackage,
188+
strictSecurity = strictVirusTotalCheck,
182189
onClearHistory = viewModel::clearHistory,
183190
onCheckVirusTotal = { viewModel.scanVirusTotal(context) },
184191
onStartDeviceScan = { viewModel.startDeviceScan(context) },
@@ -251,6 +258,7 @@ private fun InstallUi(
251258
onRetry: (java.util.UUID) -> Unit = {},
252259
onDismissSession: (java.util.UUID) -> Unit = {},
253260
onUnblock: (String) -> Unit = {},
261+
strictSecurity: Boolean = false,
254262
onClearHistory: () -> Unit = {},
255263
onCheckVirusTotal: () -> Unit = {},
256264
onStartDeviceScan: () -> Unit = {},
@@ -493,6 +501,7 @@ private fun InstallUi(
493501
onSelectUserId = onSelectUserId,
494502
startCompact = true,
495503
onUnblock = onUnblock,
504+
strictSecurity = strictSecurity,
496505
)
497506
}
498507
}

app/src/main/java/app/pwhs/universalinstaller/presentation/setting/SettingScreen.kt

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ fun SettingScreen(
101101
val dhizukuState by viewModel.dhizukuState.collectAsState()
102102
val useDhizuku by viewModel.useDhizuku.collectAsState()
103103
val blacklist by viewModel.blacklist.collectAsState()
104+
val securityLevel by viewModel.securityLevel.collectAsState()
104105
val context = androidx.compose.ui.platform.LocalContext.current
105106

106107
// Dhizuku can be granted or revoked in its own app while we are backgrounded.
@@ -120,7 +121,8 @@ fun SettingScreen(
120121
uiState = uiState,
121122
onInstallModeChanged = viewModel::setInstallMode,
122123
onVirusTotalKeyChanged = viewModel::setVirusTotalApiKey,
123-
onStrictVirusTotalCheckChanged = viewModel::setStrictVirusTotalCheck,
124+
securityLevel = securityLevel,
125+
onSecurityLevelChanged = viewModel::setSecurityLevel,
124126
onShizukuOptionChanged = viewModel::setShizukuOption,
125127
dhizukuState = dhizukuState,
126128
useDhizuku = useDhizuku,
@@ -170,7 +172,8 @@ private fun SettingUi(
170172
uiState: SettingUiState = SettingUiState(),
171173
onInstallModeChanged: (InstallMode) -> Unit = {},
172174
onVirusTotalKeyChanged: (String) -> Unit = {},
173-
onStrictVirusTotalCheckChanged: (Boolean) -> Unit = {},
175+
securityLevel: SecurityLevel = SecurityLevel.Normal,
176+
onSecurityLevelChanged: (SecurityLevel) -> Unit = {},
174177
onShizukuOptionChanged: (Preferences.Key<Boolean>, Boolean) -> Unit = { _, _ -> },
175178
onReplayTutorial: () -> Unit = {},
176179
// Not in SettingUiState: that is built by an index-based combine() and extending it means
@@ -691,11 +694,9 @@ private fun SettingUi(
691694
placeholder = { Text(stringResource(R.string.setting_vt_api_key_placeholder)) },
692695
singleLine = true,
693696
)
694-
SwitchPreference(
695-
title = stringResource(R.string.setting_vt_strict_check_title),
696-
subtitle = stringResource(R.string.setting_vt_strict_check_summary),
697-
checked = uiState.strictVirusTotalCheck,
698-
onCheckedChange = onStrictVirusTotalCheckChanged
697+
SecurityLevelSelector(
698+
current = securityLevel,
699+
onChange = onSecurityLevelChanged,
699700
)
700701
}
701702
}
@@ -994,3 +995,56 @@ private fun InstallModeSelector(
994995
)
995996
}
996997
}
998+
999+
1000+
/**
1001+
* Normal vs Strict, as a segmented pair rather than a switch.
1002+
*
1003+
* A switch labelled "strict check" said nothing about what normal was, and the pushy Scan button
1004+
* was on regardless — including for the many users with no API key at all. Two named levels make
1005+
* the trade explicit and let Normal actually mean "stay out of the way".
1006+
*/
1007+
@OptIn(ExperimentalMaterial3Api::class)
1008+
@Composable
1009+
private fun SecurityLevelSelector(
1010+
current: SecurityLevel,
1011+
onChange: (SecurityLevel) -> Unit,
1012+
) {
1013+
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
1014+
Text(
1015+
text = stringResource(R.string.setting_security_level_title),
1016+
style = MaterialTheme.typography.titleSmall,
1017+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1018+
modifier = Modifier.padding(bottom = 8.dp),
1019+
)
1020+
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
1021+
SecurityLevel.entries.forEachIndexed { index, level ->
1022+
SegmentedButton(
1023+
selected = level == current,
1024+
onClick = { if (level != current) onChange(level) },
1025+
shape = SegmentedButtonDefaults.itemShape(
1026+
index = index,
1027+
count = SecurityLevel.entries.size,
1028+
),
1029+
label = {
1030+
Text(
1031+
when (level) {
1032+
SecurityLevel.Normal -> stringResource(R.string.setting_security_normal)
1033+
SecurityLevel.Strict -> stringResource(R.string.setting_security_strict)
1034+
}
1035+
)
1036+
},
1037+
)
1038+
}
1039+
}
1040+
Spacer(Modifier.height(8.dp))
1041+
Text(
1042+
text = when (current) {
1043+
SecurityLevel.Normal -> stringResource(R.string.setting_security_normal_sub)
1044+
SecurityLevel.Strict -> stringResource(R.string.setting_security_strict_sub)
1045+
},
1046+
style = MaterialTheme.typography.bodySmall,
1047+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1048+
)
1049+
}
1050+
}

app/src/main/java/app/pwhs/universalinstaller/presentation/setting/SettingViewModel.kt

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ object PreferencesKeys {
6161
val INSTALL_USER_ID = intPreferencesKey("install_user_id")
6262
val VIRUSTOTAL_API_KEY = stringPreferencesKey("virustotal_api_key")
6363
val STRICT_VIRUSTOTAL_CHECK = booleanPreferencesKey("strict_virustotal_check")
64+
val SECURITY_LEVEL = stringPreferencesKey("security_level")
6465
val DELETE_APK_AFTER_INSTALL = booleanPreferencesKey("delete_apk_after_install")
6566

6667
/** Open the app automatically after a successful install (with a 3-second cancellable countdown). */
@@ -185,6 +186,30 @@ fun RootOptions.asCommon() = CommonInstallOptions(
185186
bypassLowTargetSdk, allUsers, setInstallSource, installerPackageName,
186187
)
187188

189+
/**
190+
* How hard the app pushes VirusTotal.
191+
*
192+
* [Normal] is the default: scanning stays available but never takes over the UI, and installing
193+
* an unscanned file is not treated as a risk. [Strict] is the previous behaviour — Scan becomes
194+
* the primary action until a verdict exists, and an unscanned file raises the risk dialog.
195+
*
196+
* Replaces the old STRICT_VIRUSTOTAL_CHECK boolean, which only covered the risk gate and left the
197+
* pushy button behaviour on for everyone, including people with no API key.
198+
*/
199+
enum class SecurityLevel {
200+
Normal, Strict;
201+
202+
companion object {
203+
/**
204+
* @param legacyStrict the old STRICT_VIRUSTOTAL_CHECK value, honoured when the new key
205+
* has never been written — someone who opted into strict checking keeps it.
206+
*/
207+
fun from(stored: String?, legacyStrict: Boolean = false): SecurityLevel =
208+
entries.firstOrNull { it.name == stored }
209+
?: if (legacyStrict) Strict else Normal
210+
}
211+
}
212+
188213
enum class InstallMode {
189214
DEFAULT, SHIZUKU, ROOT;
190215

@@ -243,7 +268,6 @@ data class SettingUiState(
243268
val useShizuku: Boolean = false,
244269
val useRoot: Boolean = false,
245270
val virusTotalApiKey: String = "",
246-
val strictVirusTotalCheck: Boolean = false,
247271
val deleteApkAfterInstall: Boolean = false,
248272
val autoOpenAfterInstall: Boolean = false,
249273
val shizukuState: ShizukuState = ShizukuState.NOT_INSTALLED,
@@ -472,6 +496,30 @@ class SettingViewModel(
472496
}
473497
}
474498

499+
/**
500+
* Normal vs Strict. Its own flow rather than a SettingUiState field: that state is assembled
501+
* by an index-based combine whose interfaceFlags slot is a List<Boolean>, and this is neither
502+
* a boolean nor worth renumbering the block for.
503+
*/
504+
val securityLevel: StateFlow<SecurityLevel> = dataStore.data
505+
.map {
506+
SecurityLevel.from(
507+
stored = it[PreferencesKeys.SECURITY_LEVEL],
508+
legacyStrict = it[PreferencesKeys.STRICT_VIRUSTOTAL_CHECK] ?: false,
509+
)
510+
}
511+
.stateIn(viewModelScope, SharingStarted.Eagerly, SecurityLevel.Normal)
512+
513+
fun setSecurityLevel(level: SecurityLevel) {
514+
viewModelScope.launch {
515+
dataStore.edit { prefs ->
516+
prefs[PreferencesKeys.SECURITY_LEVEL] = level.name
517+
// Keep the legacy key in step so anything still reading it agrees.
518+
prefs[PreferencesKeys.STRICT_VIRUSTOTAL_CHECK] = level == SecurityLevel.Strict
519+
}
520+
}
521+
}
522+
475523
/** Packages the user has blocked from ever being installed. */
476524
val blacklist: StateFlow<List<String>> = dataStore.data
477525
.map { InstallBlacklist.read(it).sorted() }
@@ -982,7 +1030,6 @@ class SettingViewModel(
9821030
useShizuku = useShizuku,
9831031
useRoot = useRoot && (rootState == RootState.READY || rootState == RootState.UNKNOWN),
9841032
virusTotalApiKey = vtKey,
985-
strictVirusTotalCheck = strictVirusTotal,
9861033
deleteApkAfterInstall = deleteApk,
9871034
autoOpenAfterInstall = autoOpen,
9881035
shizukuState = shizukuState,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,11 @@
728728
<string name="setting_section_advanced">Nâng cao</string>
729729
<string name="setting_vt_api_key_title">Khóa API VirusTotal</string>
730730
<string name="setting_vt_api_key_placeholder">Dán khóa API vào đây…</string>
731+
<string name="setting_security_level_title">Mức bảo mật</string>
732+
<string name="setting_security_normal">Bình thường</string>
733+
<string name="setting_security_strict">Nghiêm ngặt</string>
734+
<string name="setting_security_normal_sub">Cài đặt là nút chính. VirusTotal vẫn nằm trong phần chi tiết khi bạn cần.</string>
735+
<string name="setting_security_strict_sub">Quét VirusTotal trở thành nút chính cho tới khi file có kết quả, và cài thứ chưa quét sẽ phải xác nhận. Cần API key.</string>
731736
<string name="setting_vt_strict_check_title">Kiểm tra VirusTotal nghiêm ngặt</string>
732737
<string name="setting_vt_strict_check_summary">Cảnh báo trước khi cài đặt nếu tệp chưa được quét</string>
733738
<string name="setting_diagnostics_title">Chẩn đoán</string>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,11 @@
769769
<string name="scan_virustotal_btn">扫描 VirusTotal</string>
770770
<string name="skip_and_install_btn">跳过并安装</string>
771771
<string name="scanning_progress">正在扫描…</string>
772+
<string name="setting_security_level_title">安全级别</string>
773+
<string name="setting_security_normal">普通</string>
774+
<string name="setting_security_strict">严格</string>
775+
<string name="setting_security_normal_sub">安装是主按钮。需要时仍可在详情中使用 VirusTotal。</string>
776+
<string name="setting_security_strict_sub">在文件有扫描结果之前,VirusTotal 扫描会成为主按钮;安装未扫描的文件时会要求确认。需要 API 密钥。</string>
772777
<string name="setting_vt_strict_check_title">严格的 VirusTotal 检查</string>
773778
<string name="setting_vt_strict_check_summary">如果文件未扫描,在安装前警告</string>
774779
</resources>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,11 @@
754754
<string name="setting_section_advanced">Advanced</string>
755755
<string name="setting_vt_api_key_title">VirusTotal API Key</string>
756756
<string name="setting_vt_api_key_placeholder">Paste API key here…</string>
757+
<string name="setting_security_level_title">Security level</string>
758+
<string name="setting_security_normal">Normal</string>
759+
<string name="setting_security_strict">Strict</string>
760+
<string name="setting_security_normal_sub">Install is the main button. VirusTotal is still there in the details when you want it.</string>
761+
<string name="setting_security_strict_sub">Scan with VirusTotal becomes the main button until a file has a verdict, and installing something unscanned asks for confirmation. Needs an API key.</string>
757762
<string name="setting_vt_strict_check_title">Strict VirusTotal check</string>
758763
<string name="setting_vt_strict_check_summary">Warn before installing if the file has not been scanned</string> <string name="setting_diagnostics_title">Diagnostics</string>
759764
<string name="setting_installer_package_title">Installer Package</string>

core/src/main/java/app/pwhs/core/data/local/SharedDataStore.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import androidx.datastore.core.DataStore
55
import androidx.datastore.preferences.core.Preferences
66
import androidx.datastore.preferences.core.booleanPreferencesKey
7+
import androidx.datastore.preferences.core.stringPreferencesKey
78
import androidx.datastore.preferences.preferencesDataStore
89

910
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -13,4 +14,13 @@ object SharedPrefsKeys {
1314

1415
/** TV: when true (default) and root is available, install silently via the root shell. */
1516
val ROOT_SILENT_INSTALL = booleanPreferencesKey("tv_root_silent_install")
17+
18+
/**
19+
* "Normal" or "Strict". Declared here because onboarding lives in :core but the setting is
20+
* read by the phone app's install flow; both sides must agree on the key name.
21+
*/
22+
val SECURITY_LEVEL = stringPreferencesKey("security_level")
23+
24+
/** Legacy companion to [SECURITY_LEVEL], kept in step so older read sites still agree. */
25+
val STRICT_VIRUSTOTAL_CHECK = booleanPreferencesKey("strict_virustotal_check")
1626
}

0 commit comments

Comments
 (0)