@@ -2,6 +2,7 @@ package app.pwhs.universalinstaller.presentation.install
22
33import android.text.format.Formatter
44import androidx.compose.animation.animateContentSize
5+ import androidx.compose.foundation.BorderStroke
56import androidx.compose.foundation.Canvas
67import androidx.compose.foundation.Image
78import androidx.compose.foundation.background
@@ -34,6 +35,7 @@ import androidx.compose.material.icons.rounded.Delete
3435import androidx.compose.material.icons.rounded.DeleteSweep
3536import androidx.compose.material.icons.rounded.ExpandLess
3637import androidx.compose.material.icons.rounded.ExpandMore
38+ import androidx.compose.material.icons.rounded.GppGood
3739import androidx.compose.material.icons.rounded.InstallMobile
3840import androidx.compose.material.icons.rounded.Memory
3941import androidx.compose.material.icons.rounded.Menu
@@ -48,7 +50,7 @@ import androidx.compose.material3.CardDefaults
4850import androidx.compose.material3.Checkbox
4951import androidx.compose.material3.CircularProgressIndicator
5052import androidx.compose.material3.DropdownMenuItem
51- import androidx.compose.material3.ElevatedCard
53+ import androidx.compose.material3.OutlinedCard
5254import androidx.compose.material3.ExperimentalMaterial3Api
5355import androidx.compose.material3.ExposedDropdownMenuAnchorType
5456import 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
701826private 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
745880private 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
765960internal 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
787982private 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 ->
0 commit comments