Skip to content

Commit 878823e

Browse files
nqmgamingclaude
andcommitted
fix(virustotal): say what the scan found
A finished scan showed nothing. Three separate causes: The verdict line was hidden whenever a verdict existed — the condition read `vtDesc != null && !hasResult`, on the assumption the breakdown bar conveyed it. The bar is a bare 8dp Canvas with no words in it, so CLEAN, MALICIOUS and SUSPICIOUS all rendered silent. The bar was grey for every clean file. It coloured only malicious, suspicious and harmless, but for files VirusTotal reports engines that found nothing under `undetected` and leaves `harmless` at 0 — so a clean APK got three zero-width segments and a full-width grey remainder. Grey now means "no data" and nothing else. Per-engine results were parsed and thrown away. `engineResults` had no reader anywhere in the UI, and the only route to any detail was clicking the whole card to open a browser, which nothing signposted. There is now a detection ratio, an expandable engine list with flagged engines first, and an explicit "View on VirusTotal" button in place of the invisible card tap. The compact sheet has the same gap: the VirusTotal card lives inside the `isExpanded` branch, so the sheet a user actually confirms from showed no trace of a completed scan. Adds a status chip to the chip row, covering verdicts, in-progress states, and the no-verdict states (missing key, spent quota, file too large). It only appears once a scan has run — vtResult stays null until then, so nothing is claimed about an unscanned package. Cards on the detail screen move from ElevatedCard to a flat outline. Nested in the sheet, shadow plus a lighter fill stacked surface on surface on surface and read as muddy floating boxes on a dark background. A malicious verdict keeps its filled container — that one is meant to shout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 278a132 commit 878823e

4 files changed

Lines changed: 237 additions & 13 deletions

File tree

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

Lines changed: 213 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app.pwhs.universalinstaller.presentation.install
22

33
import android.text.format.Formatter
44
import androidx.compose.animation.animateContentSize
5+
import androidx.compose.foundation.BorderStroke
56
import androidx.compose.foundation.Canvas
67
import androidx.compose.foundation.Image
78
import androidx.compose.foundation.background
@@ -34,6 +35,7 @@ import androidx.compose.material.icons.rounded.Delete
3435
import androidx.compose.material.icons.rounded.DeleteSweep
3536
import androidx.compose.material.icons.rounded.ExpandLess
3637
import androidx.compose.material.icons.rounded.ExpandMore
38+
import androidx.compose.material.icons.rounded.GppGood
3739
import androidx.compose.material.icons.rounded.InstallMobile
3840
import androidx.compose.material.icons.rounded.Memory
3941
import androidx.compose.material.icons.rounded.Menu
@@ -48,7 +50,7 @@ import androidx.compose.material3.CardDefaults
4850
import androidx.compose.material3.Checkbox
4951
import androidx.compose.material3.CircularProgressIndicator
5052
import androidx.compose.material3.DropdownMenuItem
51-
import androidx.compose.material3.ElevatedCard
53+
import androidx.compose.material3.OutlinedCard
5254
import androidx.compose.material3.ExperimentalMaterial3Api
5355
import androidx.compose.material3.ExposedDropdownMenuAnchorType
5456
import androidx.compose.material3.ExposedDropdownMenuBox
@@ -277,6 +279,9 @@ internal fun ApkInfoContent(
277279
contentColor = MaterialTheme.colorScheme.onErrorContainer,
278280
)
279281
}
282+
// Scan state belongs in the compact sheet too — this is the row the user confirms from,
283+
// and the verdict used to be visible only after opening App Details.
284+
apkInfo.vtResult?.let { vt -> VtStatusChip(vt) }
280285
if (isExpanded) {
281286
if (apkInfo.versionName.isNotBlank()) {
282287
InfoChip(label = stringResource(R.string.apk_info_version_chip, apkInfo.versionName))
@@ -630,22 +635,76 @@ private fun VirusTotalCard(
630635
VtStatus.ANALYZING -> stringResource(R.string.apk_info_vt_analyzing)
631636
null -> null
632637
}
633-
ElevatedCard(onClick = onOpenLink, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.elevatedCardColors(containerColor = if (status == VtStatus.MALICIOUS) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerLow)) {
634-
Column(modifier = Modifier.padding(16.dp)) {
638+
// A malicious verdict keeps a filled container — that one is meant to shout. Everything else
639+
// uses the flat outlined shell the other sections use.
640+
val isAlarming = status == VtStatus.MALICIOUS
641+
OutlinedCard(
642+
modifier = Modifier.fillMaxWidth(),
643+
shape = MaterialTheme.shapes.extraLarge,
644+
colors = CardDefaults.outlinedCardColors(
645+
containerColor = if (isAlarming) MaterialTheme.colorScheme.errorContainer else Color.Transparent,
646+
),
647+
border = if (isAlarming) {
648+
BorderStroke(1.dp, MaterialTheme.colorScheme.error)
649+
} else {
650+
sectionCardBorder()
651+
},
652+
) {
653+
Column(modifier = Modifier.padding(16.dp).animateContentSize()) {
635654
Row(verticalAlignment = Alignment.CenterVertically) {
636655
Icon(Icons.Rounded.Security, null, tint = vtColor, modifier = Modifier.size(20.dp))
637656
Spacer(Modifier.width(8.dp))
638657
Text(stringResource(R.string.apk_info_vt_scan_title), style = MaterialTheme.typography.labelLarge, color = vtColor)
639658
if (inProgress) { Spacer(Modifier.width(8.dp)); CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = vtColor) }
640659
}
641-
// Don't duplicate the count line when the breakdown bar already conveys it.
642-
if (vtDesc != null && !hasResult) {
660+
// Always state the verdict. This used to be hidden whenever there was a result, on the
661+
// assumption the breakdown bar conveyed it — but the bar carries no words, and a clean
662+
// file colours none of its segments, so a finished scan rendered a silent grey card.
663+
if (vtDesc != null) {
643664
Spacer(Modifier.height(8.dp))
644665
Text(vtDesc, style = MaterialTheme.typography.bodySmall, color = vtColor)
645666
}
646667
if (hasResult && vt != null) {
668+
val total = vt.malicious + vt.suspicious + vt.harmless + vt.undetected
647669
Spacer(Modifier.height(12.dp))
648-
VtBreakdownSection(vt = vt, warningColor = extendedColors.warning)
670+
VtBreakdownSection(vt = vt, warningColor = extendedColors.warning, cleanColor = extendedColors.success)
671+
if (total > 0) {
672+
Spacer(Modifier.height(8.dp))
673+
Text(
674+
text = stringResource(
675+
R.string.apk_info_vt_tally,
676+
vt.malicious + vt.suspicious,
677+
total,
678+
),
679+
style = MaterialTheme.typography.bodySmall,
680+
color = MaterialTheme.colorScheme.onSurfaceVariant,
681+
)
682+
}
683+
// The per-engine results were already being parsed and thrown away; the only way to
684+
// see any detail was the card's own click opening a browser, which nothing signposted.
685+
if (vt.engineResults.isNotEmpty()) {
686+
VtEngineList(
687+
engines = vt.engineResults,
688+
warningColor = extendedColors.warning,
689+
)
690+
}
691+
if (sha256.isNotBlank()) {
692+
TextButton(
693+
onClick = onOpenLink,
694+
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
695+
) {
696+
Text(
697+
stringResource(R.string.apk_info_vt_open_web),
698+
style = MaterialTheme.typography.labelMedium,
699+
)
700+
Spacer(Modifier.width(6.dp))
701+
Icon(
702+
Icons.AutoMirrored.Rounded.OpenInNew,
703+
contentDescription = null,
704+
modifier = Modifier.size(14.dp),
705+
)
706+
}
707+
}
649708
}
650709
// Telling someone their key is missing is only half an answer — the fix is two
651710
// screens away and they are mid-install. Offer both steps here.
@@ -677,26 +736,92 @@ private fun VirusTotalCard(
677736
}
678737
}
679738

739+
/**
740+
* The malicious / suspicious / clean split as a single bar.
741+
*
742+
* `undetected` counts as clean: for files VirusTotal reports engines that found nothing under
743+
* `undetected` and leaves `harmless` at 0, so colouring only `harmless` left every clean scan
744+
* showing a fully grey bar.
745+
*/
680746
@Composable
681-
private fun VtBreakdownSection(vt: VtResult, warningColor: Color) {
747+
private fun VtBreakdownSection(vt: VtResult, warningColor: Color, cleanColor: Color) {
682748
val total = (vt.malicious + vt.suspicious + vt.harmless + vt.undetected).coerceAtLeast(1)
683749
val malFraction = vt.malicious.toFloat() / total
684750
val susFraction = vt.suspicious.toFloat() / total
685-
val harmFraction = vt.harmless.toFloat() / total
751+
val cleanFraction = (vt.harmless + vt.undetected).toFloat() / total
752+
val errorColor = MaterialTheme.colorScheme.error
686753
Canvas(modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.small)) {
687754
val w = size.width
688755
val h = size.height
689756
var x = 0f
690757
val malW = w * malFraction
691-
if (malW > 0f) { drawRect(color = Color.Red, topLeft = Offset(x, 0f), size = Size(malW, h)); x += malW }
758+
if (malW > 0f) { drawRect(color = errorColor, topLeft = Offset(x, 0f), size = Size(malW, h)); x += malW }
692759
val susW = w * susFraction
693760
if (susW > 0f) { drawRect(color = warningColor, topLeft = Offset(x, 0f), size = Size(susW, h)); x += susW }
694-
val harmW = w * harmFraction
695-
if (harmW > 0f) { drawRect(color = Color.Green, topLeft = Offset(x, 0f), size = Size(harmW, h)); x += harmW }
761+
val cleanW = w * cleanFraction
762+
if (cleanW > 0f) { drawRect(color = cleanColor, topLeft = Offset(x, 0f), size = Size(cleanW, h)); x += cleanW }
763+
// Only reached when the stats add up to nothing — grey means "no data", not "clean".
696764
drawRect(color = Color.Gray.copy(alpha = 0.3f), topLeft = Offset(x, 0f), size = Size(w - x, h))
697765
}
698766
}
699767

768+
/**
769+
* Per-engine verdicts, collapsed to just the engines that flagged the file.
770+
*
771+
* Expanding shows all engines VirusTotal returned — for a clean file the collapsed list is empty,
772+
* so the toggle is the only thing on screen until it is opened.
773+
*/
774+
@Composable
775+
private fun VtEngineList(engines: List<VtEngineResult>, warningColor: Color) {
776+
var expanded by remember { mutableStateOf(false) }
777+
val flagged = engines.filter { it.category == "malicious" || it.category == "suspicious" }
778+
val visible = if (expanded) engines else flagged
779+
Column(modifier = Modifier.fillMaxWidth()) {
780+
if (visible.isNotEmpty()) Spacer(Modifier.height(8.dp))
781+
visible.forEach { engine ->
782+
Row(
783+
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
784+
verticalAlignment = Alignment.CenterVertically,
785+
) {
786+
Text(
787+
text = engine.engineName,
788+
style = MaterialTheme.typography.bodySmall,
789+
modifier = Modifier.weight(1f),
790+
maxLines = 1,
791+
overflow = TextOverflow.Ellipsis,
792+
)
793+
Spacer(Modifier.width(8.dp))
794+
Text(
795+
text = engine.result ?: engine.category,
796+
style = MaterialTheme.typography.bodySmall,
797+
color = when (engine.category) {
798+
"malicious" -> MaterialTheme.colorScheme.error
799+
"suspicious" -> warningColor
800+
else -> MaterialTheme.colorScheme.onSurfaceVariant
801+
},
802+
maxLines = 1,
803+
overflow = TextOverflow.Ellipsis,
804+
)
805+
}
806+
}
807+
TextButton(
808+
onClick = { expanded = !expanded },
809+
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
810+
) {
811+
Text(
812+
// Deliberately uncounted: this list includes engines that could not scan the file
813+
// at all, so its size contradicts the "n of m flagged" ratio just above it.
814+
text = if (expanded) {
815+
stringResource(R.string.apk_info_vt_engines_hide)
816+
} else {
817+
stringResource(R.string.apk_info_vt_engines_show)
818+
},
819+
style = MaterialTheme.typography.labelMedium,
820+
)
821+
}
822+
}
823+
}
824+
700825
@Composable
701826
private fun PermissionsCard(permissions: List<String>) {
702827
var expanded by remember { mutableStateOf(false) }
@@ -741,10 +866,25 @@ private fun SplitsCard(splits: List<SplitEntry>, onToggle: (Int) -> Unit) {
741866
}
742867
}
743868

869+
/**
870+
* The one border every install-detail section shares.
871+
*
872+
* These used to be [androidx.compose.material3.ElevatedCard]s: inside the detail sheet the shadow
873+
* plus a lighter fill stacked surface on surface on surface, which read as muddy floating boxes on
874+
* a dark background. Flat outlines keep the grouping without the layering.
875+
*/
876+
@Composable
877+
private fun sectionCardBorder() = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
878+
744879
@Composable
745880
private fun SectionCard(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, summary: String? = null, badge: String? = null, defaultExpanded: Boolean = true, content: @Composable () -> Unit) {
746881
var expanded by remember { mutableStateOf(defaultExpanded) }
747-
ElevatedCard(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) {
882+
OutlinedCard(
883+
modifier = Modifier.fillMaxWidth(),
884+
shape = MaterialTheme.shapes.extraLarge,
885+
colors = CardDefaults.outlinedCardColors(containerColor = Color.Transparent),
886+
border = sectionCardBorder(),
887+
) {
748888
Column(modifier = Modifier.animateContentSize()) {
749889
Row(modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
750890
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
@@ -761,6 +901,61 @@ private fun SectionCard(icon: androidx.compose.ui.graphics.vector.ImageVector, t
761901
}
762902
}
763903

904+
/**
905+
* One-chip VirusTotal state for the chip row, shown collapsed and expanded alike.
906+
*
907+
* [ApkInfo.vtResult] stays null until a scan is asked for, so this never claims anything about a
908+
* package nobody scanned.
909+
*/
910+
@Composable
911+
private fun VtStatusChip(vt: VtResult) {
912+
val extendedColors = LocalExtendedColors.current
913+
when (vt.status) {
914+
VtStatus.CLEAN -> InfoChip(
915+
label = stringResource(R.string.apk_info_vt_chip_clean),
916+
leadingIcon = {
917+
Icon(Icons.Rounded.GppGood, null, modifier = Modifier.size(16.dp), tint = extendedColors.success)
918+
},
919+
contentColor = extendedColors.success,
920+
)
921+
VtStatus.MALICIOUS, VtStatus.SUSPICIOUS -> {
922+
val alarming = vt.status == VtStatus.MALICIOUS
923+
InfoChip(
924+
label = stringResource(R.string.apk_info_vt_chip_flagged, vt.malicious + vt.suspicious),
925+
leadingIcon = {
926+
Icon(
927+
Icons.Rounded.Warning,
928+
null,
929+
modifier = Modifier.size(16.dp),
930+
tint = if (alarming) MaterialTheme.colorScheme.onErrorContainer else extendedColors.warning,
931+
)
932+
},
933+
containerColor = if (alarming) MaterialTheme.colorScheme.errorContainer else extendedColors.warningContainer,
934+
contentColor = if (alarming) MaterialTheme.colorScheme.onErrorContainer else extendedColors.warning,
935+
)
936+
}
937+
VtStatus.SCANNING, VtStatus.UPLOADING, VtStatus.QUEUED, VtStatus.ANALYZING -> InfoChip(
938+
label = stringResource(R.string.apk_info_vt_chip_scanning),
939+
leadingIcon = {
940+
CircularProgressIndicator(
941+
modifier = Modifier.size(12.dp),
942+
strokeWidth = 1.5.dp,
943+
color = MaterialTheme.colorScheme.onSurfaceVariant,
944+
)
945+
},
946+
)
947+
// Every remaining state is a scan that produced no verdict — missing key, bad key, quota
948+
// spent, file too big, unknown to VirusTotal. Say so rather than showing nothing.
949+
else -> InfoChip(
950+
label = stringResource(R.string.apk_info_vt_chip_no_result),
951+
leadingIcon = {
952+
Icon(Icons.Rounded.Security, null, modifier = Modifier.size(16.dp), tint = extendedColors.warning)
953+
},
954+
contentColor = extendedColors.warning,
955+
)
956+
}
957+
}
958+
764959
@Composable
765960
internal fun InfoChip(label: String, leadingIcon: @Composable (() -> Unit)? = null, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
766961
Surface(shape = MaterialTheme.shapes.small, color = containerColor, contentColor = contentColor) {
@@ -785,7 +980,12 @@ internal fun sdkToAndroid(sdk: Int): String = when {
785980

786981
@Composable
787982
private fun ObbAttachCard(attached: List<AttachedObb>, onAttach: () -> Unit, onRemove: (AttachedObb) -> Unit) {
788-
ElevatedCard(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) {
983+
OutlinedCard(
984+
modifier = Modifier.fillMaxWidth(),
985+
shape = MaterialTheme.shapes.extraLarge,
986+
colors = CardDefaults.outlinedCardColors(containerColor = Color.Transparent),
987+
border = sectionCardBorder(),
988+
) {
789989
Column(modifier = Modifier.padding(16.dp)) {
790990
Text(stringResource(R.string.apk_info_obb_attach_title), style = MaterialTheme.typography.titleSmall)
791991
attached.forEach { obb ->

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@
177177
<string name="apk_info_vt_uploading">Đang tải lên VirusTotal… %d%%</string>
178178
<string name="apk_info_vt_add_key">Thêm API key</string>
179179
<string name="apk_info_vt_get_key">Lấy key miễn phí</string>
180+
<string name="apk_info_vt_tally">%1$d trên %2$d engine đánh dấu file này</string>
181+
<string name="apk_info_vt_engines_show">Kết quả từng engine</string>
182+
<string name="apk_info_vt_engines_hide">Ẩn kết quả từng engine</string>
183+
<string name="apk_info_vt_open_web">Xem trên VirusTotal</string>
184+
<string name="apk_info_vt_chip_clean">VirusTotal: sạch</string>
185+
<string name="apk_info_vt_chip_flagged">VirusTotal: %1$d engine đánh dấu</string>
186+
<string name="apk_info_vt_chip_scanning">VirusTotal: đang quét…</string>
187+
<string name="apk_info_vt_chip_no_result">VirusTotal: chưa có kết quả</string>
180188
<string name="apk_info_vt_check_button">Kiểm tra với VirusTotal</string>
181189
<string name="apk_info_vt_rescan_button">Quét lại</string>
182190
<string name="vt_notif_channel_name">Quét VirusTotal</string>

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@
174174
<string name="apk_info_vt_uploading">正在上传至 VirusTotal… %d%%</string>
175175
<string name="apk_info_vt_add_key">添加 API 密钥</string>
176176
<string name="apk_info_vt_get_key">获取免费密钥</string>
177+
<string name="apk_info_vt_tally">%2$d 个引擎中有 %1$d 个标记了此文件</string>
178+
<string name="apk_info_vt_engines_show">各引擎结果</string>
179+
<string name="apk_info_vt_engines_hide">隐藏各引擎结果</string>
180+
<string name="apk_info_vt_open_web">在 VirusTotal 中查看</string>
181+
<string name="apk_info_vt_chip_clean">VirusTotal:安全</string>
182+
<string name="apk_info_vt_chip_flagged">VirusTotal:%1$d 个引擎标记</string>
183+
<string name="apk_info_vt_chip_scanning">VirusTotal:扫描中…</string>
184+
<string name="apk_info_vt_chip_no_result">VirusTotal:暂无结果</string>
177185
<string name="apk_info_vt_check_button">使用 VirusTotal 检查</string>
178186
<string name="apk_info_vt_rescan_button">重新扫描</string>
179187
<string name="vt_notif_channel_name">VirusTotal 扫描</string>

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,14 @@
253253
<string name="apk_info_vt_too_large">File too large for VirusTotal (%s)</string>
254254
<string name="apk_info_vt_add_key">Add API key</string>
255255
<string name="apk_info_vt_get_key">Get a free key</string>
256+
<string name="apk_info_vt_tally">%1$d of %2$d engines flagged this file</string>
257+
<string name="apk_info_vt_engines_show">Engine results</string>
258+
<string name="apk_info_vt_engines_hide">Hide engine results</string>
259+
<string name="apk_info_vt_open_web">View on VirusTotal</string>
260+
<string name="apk_info_vt_chip_clean">VirusTotal: clean</string>
261+
<string name="apk_info_vt_chip_flagged">VirusTotal: %1$d flagged</string>
262+
<string name="apk_info_vt_chip_scanning">VirusTotal: scanning…</string>
263+
<string name="apk_info_vt_chip_no_result">VirusTotal: no result</string>
256264
<string name="apk_info_vt_check_button">Check with VirusTotal</string>
257265
<string name="apk_info_vt_rescan_button">Scan again</string>
258266
<string name="vt_notif_channel_name">VirusTotal scans</string>

0 commit comments

Comments
 (0)