66package app.morphe.manager.ui.screen.home
77
88import android.annotation.SuppressLint
9+ import android.os.Build
910import android.widget.Toast
1011import androidx.compose.animation.AnimatedVisibility
1112import androidx.compose.animation.core.tween
@@ -25,6 +26,7 @@ import androidx.compose.material3.*
2526import androidx.compose.runtime.*
2627import androidx.compose.ui.Alignment
2728import androidx.compose.ui.Modifier
29+ import androidx.compose.ui.draw.alpha
2830import androidx.compose.ui.graphics.Color
2931import androidx.compose.ui.platform.LocalContext
3032import androidx.compose.ui.platform.LocalUriHandler
@@ -50,6 +52,7 @@ import app.morphe.manager.ui.viewmodel.SavedApkInfo
5052import app.morphe.manager.util.KnownApps
5153import app.morphe.manager.util.MppManifest
5254import app.morphe.manager.util.RemoteAvatar
55+ import app.morphe.manager.util.androidVersionName
5356import app.morphe.manager.util.htmlAnnotatedString
5457import app.morphe.manager.util.toast
5558import app.morphe.patcher.patch.AppTarget
@@ -242,6 +245,21 @@ fun HomeDialogs(
242245 )
243246 }
244247
248+ // No compatible versions dialog - shown when every declared version requires a higher SDK
249+ AnimatedVisibility (
250+ visible = homeViewModel.showNoCompatibleVersionsDialog != null ,
251+ enter = fadeIn(tween(MorpheDefaults .ANIMATION_DURATION )),
252+ exit = fadeOut(tween(MorpheDefaults .ANIMATION_DURATION ))
253+ ) {
254+ val packageName = homeViewModel.showNoCompatibleVersionsDialog ? : return @AnimatedVisibility
255+ val appName = homeViewModel.bundleAppMetadataFlow.value[packageName]?.displayName
256+ ? : KnownApps .getAppName(packageName)
257+ NoCompatibleVersionsDialog (
258+ appName = appName,
259+ onDismiss = { homeViewModel.showNoCompatibleVersionsDialog = null }
260+ )
261+ }
262+
245263 // Split APK Warning Dialog - shown when user picks a split APK for an app that prefers full APK
246264 if (homeViewModel.showSplitApkWarningDialog) {
247265 val appName = homeViewModel.pendingAppName ? : " "
@@ -504,6 +522,18 @@ private fun ApkAvailabilityDialog(
504522 onNeedApk : () -> Unit ,
505523 onUseSaved : () -> Unit
506524) {
525+ val deviceSdk = Build .VERSION .SDK_INT
526+
527+ // Versions whose minSdk exceeds the current device - shown greyed-out and non-selectable
528+ val incompatibleSdkVersions: Set <String > = remember(compatibleVersions, deviceSdk) {
529+ compatibleVersions
530+ .mapNotNull { b ->
531+ val v = b.target.version ? : return @mapNotNull null
532+ val minSdk = b.target.minSdk ? : return @mapNotNull null
533+ if (deviceSdk < minSdk) v else null
534+ }
535+ .toSet()
536+ }
507537 MorpheDialog (
508538 onDismissRequest = onDismiss,
509539 title = stringResource(R .string.home_apk_availability_dialog_title),
@@ -566,7 +596,8 @@ private fun ApkAvailabilityDialog(
566596 recommendedBundleVersions = recommendedBundleVersions,
567597 onVersionSelect = onVersionSelect,
568598 anyString = anyString,
569- hasMultipleBundles = compatibleVersions.map { it.bundleUid }.distinct().size > 1
599+ hasMultipleBundles = compatibleVersions.map { it.bundleUid }.distinct().size > 1 ,
600+ incompatibleSdkVersions = incompatibleSdkVersions,
570601 )
571602 } else {
572603 VersionListCard (
@@ -577,7 +608,8 @@ private fun ApkAvailabilityDialog(
577608 .toSet(),
578609 descriptions = compatibleVersions
579610 .mapNotNull { b -> b.target.version?.let { v -> b.target.description?.let { d -> v to d } } }
580- .toMap()
611+ .toMap(),
612+ incompatibleSdkVersions = incompatibleSdkVersions,
581613 )
582614 }
583615 } else {
@@ -1210,19 +1242,72 @@ fun WrongPackageDialog(
12101242}
12111243
12121244/* *
1213- * Version list card where each row is tappable.
1214- * The selected version gets a checkmark; the recommended version is labeled when not selected.
1215- * Experimental versions are always labeled regardless of selection state.
1245+ * Shown when the device SDK is lower than the minSdk of every declared AppTarget for this app.
1246+ * Informs the user that their device does not meet the requirements for any supported version.
12161247 */
12171248@Composable
1249+ private fun NoCompatibleVersionsDialog (
1250+ appName : String ,
1251+ onDismiss : () -> Unit
1252+ ) {
1253+ val deviceSdk = Build .VERSION .SDK_INT
1254+
1255+ MorpheDialog (
1256+ onDismissRequest = onDismiss,
1257+ title = stringResource(R .string.home_apk_no_compatible_versions_title),
1258+ footer = {
1259+ MorpheDialogButton (
1260+ text = stringResource(android.R .string.ok),
1261+ onClick = onDismiss,
1262+ modifier = Modifier .fillMaxWidth()
1263+ )
1264+ }
1265+ ) {
1266+ Column (
1267+ modifier = Modifier .fillMaxWidth(),
1268+ verticalArrangement = Arrangement .spacedBy(16 .dp),
1269+ horizontalAlignment = Alignment .CenterHorizontally
1270+ ) {
1271+ Icon (
1272+ imageVector = Icons .Outlined .PhoneAndroid ,
1273+ contentDescription = null ,
1274+ tint = MaterialTheme .colorScheme.error,
1275+ modifier = Modifier .size(48 .dp)
1276+ )
1277+ Text (
1278+ text = htmlAnnotatedString(
1279+ stringResource(
1280+ R .string.home_apk_no_compatible_versions_message,
1281+ appName,
1282+ deviceSdk.androidVersionName(),
1283+ deviceSdk
1284+ )
1285+ ),
1286+ style = MaterialTheme .typography.bodyLarge,
1287+ color = LocalDialogSecondaryTextColor .current,
1288+ textAlign = TextAlign .Center ,
1289+ modifier = Modifier .fillMaxWidth()
1290+ )
1291+ }
1292+ }
1293+ }
1294+
1295+ /* *
1296+ * The selected version gets a checkmark; the recommended version is labeled when not selected.
1297+ * Experimental versions are always labeled regardless of selection state.
1298+ * Versions whose [AppTarget.minSdk] exceeds the current device SDK are shown greyed-out
1299+ * and cannot be selected.
1300+ */
1301+ @Composable
12181302private fun SelectableVersionListCard (
1303+ modifier : Modifier = Modifier ,
12191304 versions : List <BundledAppTarget >,
12201305 selectedVersion : AppTarget ? ,
12211306 recommendedBundleVersions : Map <Int , AppTarget >,
12221307 onVersionSelect : (AppTarget ) -> Unit ,
12231308 anyString : String ,
12241309 hasMultipleBundles : Boolean ,
1225- modifier : Modifier = Modifier
1310+ incompatibleSdkVersions : Set < String > = emptySet()
12261311) {
12271312 if (versions.isEmpty()) return
12281313
@@ -1238,12 +1323,16 @@ private fun SelectableVersionListCard(
12381323 versions.forEachIndexed { index, bundled ->
12391324 val target = bundled.target
12401325 val versionString = target.version ? : anyString
1241- val isSelected = target.version != null && target.version == selectedVersion?.version
1242- val isRecommended = target.version != null &&
1326+ val isIncompatibleSdk = target.version != null && target.version in incompatibleSdkVersions
1327+ val isSelected = ! isIncompatibleSdk && target.version != null && target.version == selectedVersion?.version
1328+ val isRecommended = ! isIncompatibleSdk && target.version != null &&
12431329 target.version == recommendedBundleVersions[bundled.bundleUid]?.version
12441330 val recommendedLabel = stringResource(R .string.home_apk_availability_recommended_label)
12451331 val experimentalLabel = stringResource(R .string.home_dialog_unsupported_version_experimental_label)
12461332 val selectedLabel = stringResource(R .string.home_selected_version)
1333+ val requiresAndroidLabel = target.minSdk?.let { sdk ->
1334+ stringResource(R .string.home_version_requires_android, sdk.androidVersionName())
1335+ }
12471336
12481337 // Bundle section header - only when multiple bundles are present and uid changes
12491338 if (hasMultipleBundles && bundled.bundleUid != lastBundleUid) {
@@ -1278,6 +1367,13 @@ private fun SelectableVersionListCard(
12781367 }
12791368
12801369 val badge: @Composable (() -> Unit )? = when {
1370+ isIncompatibleSdk -> ({
1371+ InfoBadge (
1372+ text = requiresAndroidLabel ? : " API ${target.minSdk ? : " ?" } +" ,
1373+ style = InfoBadgeStyle .Error ,
1374+ isCompact = true
1375+ )
1376+ })
12811377 target.isExperimental -> ({
12821378 InfoBadge (
12831379 text = experimentalLabel,
@@ -1298,6 +1394,7 @@ private fun SelectableVersionListCard(
12981394 val rowContentDesc = buildString {
12991395 append(versionString)
13001396 when {
1397+ isIncompatibleSdk -> requiresAndroidLabel?.let { append(" , $it " ) }
13011398 target.isExperimental -> append(" , $experimentalLabel " )
13021399 isRecommended -> append(" , $recommendedLabel " )
13031400 }
@@ -1309,10 +1406,13 @@ private fun SelectableVersionListCard(
13091406 Row (
13101407 modifier = Modifier
13111408 .fillMaxWidth()
1312- .selectable(
1313- selected = isSelected,
1314- onClick = { onVersionSelect(target) },
1315- role = Role .RadioButton
1409+ .then(
1410+ if (isIncompatibleSdk) Modifier
1411+ else Modifier .selectable(
1412+ selected = isSelected,
1413+ onClick = { onVersionSelect(target) },
1414+ role = Role .RadioButton
1415+ )
13161416 )
13171417 .semantics { contentDescription = rowContentDesc }
13181418 .padding(horizontal = 16 .dp, vertical = 12 .dp),
@@ -1335,7 +1435,9 @@ private fun SelectableVersionListCard(
13351435 }
13361436
13371437 Column (
1338- modifier = Modifier .weight(1f ),
1438+ modifier = Modifier
1439+ .weight(1f )
1440+ .then(if (isIncompatibleSdk) Modifier .alpha(0.4f ) else Modifier ),
13391441 verticalArrangement = Arrangement .spacedBy(2 .dp)
13401442 ) {
13411443 Row (
@@ -1348,6 +1450,7 @@ private fun SelectableVersionListCard(
13481450 fontFamily = FontFamily .Monospace ,
13491451 fontWeight = if (isSelected) FontWeight .Bold else FontWeight .Normal ,
13501452 color = when {
1453+ isIncompatibleSdk -> LocalDialogTextColor .current
13511454 isSelected -> MaterialTheme .colorScheme.primary
13521455 target.isExperimental -> MaterialTheme .colorScheme.tertiary
13531456 else -> LocalDialogTextColor .current
@@ -1393,7 +1496,8 @@ private fun VersionListCard(
13931496 isCompatible : Boolean = false,
13941497 showUnpatchedBadge : Boolean = false,
13951498 experimentalVersions : Set <String > = emptySet(),
1396- descriptions : Map <String , String > = emptyMap()
1499+ descriptions : Map <String , String > = emptyMap(),
1500+ incompatibleSdkVersions : Set <String > = emptySet(),
13971501) {
13981502 if (versions.isEmpty()) return
13991503
@@ -1423,10 +1527,18 @@ private fun VersionListCard(
14231527 ) {
14241528 versions.forEachIndexed { index, version ->
14251529 val isExperimentalVersion = version in experimentalVersions
1530+ val isIncompatibleSdk = version in incompatibleSdkVersions
14261531 val versionDescription = descriptions[version]
14271532
14281533 // Resolve badge once - drives both the badge composable and version text color
14291534 val badge: @Composable (() -> Unit )? = when {
1535+ isIncompatibleSdk -> ({
1536+ InfoBadge (
1537+ text = stringResource(R .string.home_apk_availability_incompatible_label),
1538+ style = InfoBadgeStyle .Error ,
1539+ isCompact = true
1540+ )
1541+ })
14301542 isExperimentalVersion -> ({
14311543 InfoBadge (
14321544 text = stringResource(R .string.home_dialog_unsupported_version_experimental_label),
@@ -1452,7 +1564,9 @@ private fun VersionListCard(
14521564 }
14531565
14541566 Column (
1455- modifier = Modifier .fillMaxWidth(),
1567+ modifier = Modifier
1568+ .fillMaxWidth()
1569+ .then(if (isIncompatibleSdk) Modifier .alpha(0.4f ) else Modifier ),
14561570 verticalArrangement = Arrangement .spacedBy(3 .dp)
14571571 ) {
14581572 // Version + optional badge inline
0 commit comments