diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3552c09..b0f7307 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + { return preferencesDataSource.observeToggleModeConfig() } + + override suspend fun getSelectedSubscriptionId(): Int { + return preferencesDataSource.getSelectedSubscriptionId() + } + + override suspend fun setSelectedSubscriptionId(subscriptionId: Int) { + preferencesDataSource.setSelectedSubscriptionId(subscriptionId) + } + + override fun observeSelectedSubscriptionId(): Flow { + return preferencesDataSource.observeSelectedSubscriptionId() + } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt new file mode 100644 index 0000000..90c0aa1 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt @@ -0,0 +1,113 @@ +package com.supernova.networkswitch.data.repository + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import androidx.core.content.ContextCompat +import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.repository.SimRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of SimRepository that uses Android's SubscriptionManager + * to detect and retrieve information about available SIM cards + */ +@Singleton +class SimRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context +) : SimRepository { + + private val subscriptionManager: SubscriptionManager? by lazy { + context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager + } + + override suspend fun getAvailableSimCards(): List { + // Check if we have the required permission + if (!hasReadPhoneStatePermission()) { + return emptyList() + } + + val manager = subscriptionManager ?: return emptyList() + + return try { + // Get active subscriptions + val subscriptions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + manager.activeSubscriptionInfoList ?: emptyList() + } else { + emptyList() + } + + // Map to SimInfo objects + subscriptions.mapNotNull { subscriptionInfo -> + mapToSimInfo(subscriptionInfo) + } + } catch (e: SecurityException) { + // Permission was revoked or not granted + emptyList() + } catch (e: Exception) { + // Handle other potential errors + emptyList() + } + } + + /** + * Check if READ_PHONE_STATE permission is granted + */ + private fun hasReadPhoneStatePermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Map SubscriptionInfo to SimInfo domain model + */ + private fun mapToSimInfo(subscriptionInfo: SubscriptionInfo): SimInfo? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val displayName = buildDisplayName(subscriptionInfo) + + SimInfo( + subscriptionId = subscriptionInfo.subscriptionId, + simSlotIndex = subscriptionInfo.simSlotIndex, + displayName = displayName + ) + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Build a user-friendly display name for the SIM card + */ + private fun buildDisplayName(subscriptionInfo: SubscriptionInfo): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + // Try to get the display name from the subscription + val carrierName = subscriptionInfo.displayName?.toString() + val slotIndex = subscriptionInfo.simSlotIndex + + return when { + // If carrier name exists and slot index is valid + !carrierName.isNullOrBlank() && slotIndex >= 0 -> { + "$carrierName (Slot ${slotIndex + 1})" + } + // If only carrier name exists + !carrierName.isNullOrBlank() -> carrierName + // If only slot index is valid + slotIndex >= 0 -> "SIM ${slotIndex + 1}" + // Fallback + else -> "SIM ${subscriptionInfo.subscriptionId}" + } + } + return "Unknown SIM" + } +} diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt index e8d8f02..161c0f0 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt @@ -25,12 +25,16 @@ class PreferencesDataSource @Inject constructor( private val TOGGLE_MODE_A_KEY = intPreferencesKey("toggle_mode_a") private val TOGGLE_MODE_B_KEY = intPreferencesKey("toggle_mode_b") private val TOGGLE_NEXT_IS_B_KEY = booleanPreferencesKey("toggle_next_is_b") + private val SELECTED_SUBSCRIPTION_ID_KEY = intPreferencesKey("selected_subscription_id") private const val DEFAULT_CONTROL_METHOD = "SHIZUKU" private val DEFAULT_MODE_A = NetworkMode.LTE_ONLY private val DEFAULT_MODE_B = NetworkMode.NR_ONLY private const val DEFAULT_NEXT_IS_B = true + + // -1 indicates no specific SIM selected (use default) + private const val DEFAULT_SUBSCRIPTION_ID = -1 } private fun parseControlMethod(methodString: String?): ControlMethod { @@ -92,4 +96,33 @@ class PreferencesDataSource @Inject constructor( ToggleModeConfig(modeA, modeB, nextIsB) } } + + /** + * Get the selected subscription ID for the SIM card + * Returns -1 if no specific SIM is selected (use default) + */ + suspend fun getSelectedSubscriptionId(): Int { + return dataStore.data.map { preferences -> + preferences[SELECTED_SUBSCRIPTION_ID_KEY] ?: DEFAULT_SUBSCRIPTION_ID + }.first() + } + + /** + * Set the selected subscription ID for the SIM card + * Pass -1 to use the default subscription + */ + suspend fun setSelectedSubscriptionId(subscriptionId: Int) { + dataStore.edit { preferences -> + preferences[SELECTED_SUBSCRIPTION_ID_KEY] = subscriptionId + } + } + + /** + * Observe changes to the selected subscription ID + */ + fun observeSelectedSubscriptionId(): Flow { + return dataStore.data.map { preferences -> + preferences[SELECTED_SUBSCRIPTION_ID_KEY] ?: DEFAULT_SUBSCRIPTION_ID + } + } } diff --git a/app/src/main/java/com/supernova/networkswitch/di/DataModule.kt b/app/src/main/java/com/supernova/networkswitch/di/DataModule.kt index 25e4a89..f7d761e 100644 --- a/app/src/main/java/com/supernova/networkswitch/di/DataModule.kt +++ b/app/src/main/java/com/supernova/networkswitch/di/DataModule.kt @@ -6,8 +6,10 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.supernova.networkswitch.data.repository.NetworkControlRepositoryImpl import com.supernova.networkswitch.data.repository.PreferencesRepositoryImpl +import com.supernova.networkswitch.data.repository.SimRepositoryImpl import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.repository.SimRepository import dagger.Binds import dagger.Module import dagger.Provides @@ -37,6 +39,12 @@ abstract class DataModule { preferencesRepositoryImpl: PreferencesRepositoryImpl ): PreferencesRepository + @Binds + @Singleton + abstract fun bindSimRepository( + simRepositoryImpl: SimRepositoryImpl + ): SimRepository + companion object { @Provides @Singleton diff --git a/app/src/main/java/com/supernova/networkswitch/domain/model/NetworkSwitchModels.kt b/app/src/main/java/com/supernova/networkswitch/domain/model/NetworkSwitchModels.kt index 79b2661..0e6259e 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/model/NetworkSwitchModels.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/model/NetworkSwitchModels.kt @@ -89,3 +89,12 @@ sealed class CompatibilityState { data class Incompatible(val reason: String) : CompatibilityState() data class PermissionDenied(val method: ControlMethod) : CompatibilityState() } + +/** + * Represents information about a SIM card in the device + */ +data class SimInfo( + val subscriptionId: Int, + val simSlotIndex: Int, + val displayName: String +) diff --git a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt index 047b601..622af44 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt @@ -3,6 +3,7 @@ package com.supernova.networkswitch.domain.repository import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow @@ -64,4 +65,32 @@ interface PreferencesRepository { * Observe toggle mode configuration changes */ fun observeToggleModeConfig(): Flow + + /** + * Get the selected subscription ID for the SIM card + * Returns -1 if no specific SIM is selected (use default) + */ + suspend fun getSelectedSubscriptionId(): Int + + /** + * Set the selected subscription ID for the SIM card + * Pass -1 to use the default subscription + */ + suspend fun setSelectedSubscriptionId(subscriptionId: Int) + + /** + * Observe changes to the selected subscription ID + */ + fun observeSelectedSubscriptionId(): Flow +} + +/** + * Repository interface for SIM card operations + */ +interface SimRepository { + /** + * Get list of all available SIM cards in the device + * Returns empty list if permission is not granted + */ + suspend fun getAvailableSimCards(): List } diff --git a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt index 2b37404..04f3969 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt @@ -1,11 +1,14 @@ package com.supernova.networkswitch.domain.usecase +import android.telephony.SubscriptionManager import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.repository.SimRepository import javax.inject.Inject class CheckCompatibilityUseCase @Inject constructor( @@ -92,3 +95,84 @@ class UpdateToggleModeConfigUseCase @Inject constructor( preferencesRepository.setToggleModeConfig(config) } } + +/** + * Use case for getting available SIM cards in the device + */ +class GetAvailableSimsUseCase @Inject constructor( + private val simRepository: SimRepository +) { + suspend operator fun invoke(): Result> { + return try { + val sims = simRepository.getAvailableSimCards() + Result.success(sims) + } catch (e: Exception) { + Result.failure(e) + } + } +} + +/** + * Use case for getting the selected subscription ID + */ +class GetSelectedSubscriptionIdUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(): Int { + return preferencesRepository.getSelectedSubscriptionId() + } +} + +/** + * Use case for setting the selected subscription ID + */ +class SetSelectedSubscriptionIdUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(subscriptionId: Int) { + preferencesRepository.setSelectedSubscriptionId(subscriptionId) + } +} + +/** + * Use case for getting the effective subscription ID to use for network operations + * Returns the user's selected subscription ID, or the default if "Auto" is selected + * Includes validation to handle edge cases like removed SIM cards + */ +class GetEffectiveSubscriptionIdUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository, + private val simRepository: SimRepository +) { + suspend operator fun invoke(): Int { + val selectedSubId = preferencesRepository.getSelectedSubscriptionId() + + // If Auto mode (-1), use system default + if (selectedSubId == -1) { + return SubscriptionManager.getDefaultDataSubscriptionId() + } + + // Validate that the selected SIM still exists + val availableSims = try { + simRepository.getAvailableSimCards() + } catch (e: Exception) { + // If we can't check, use the selected ID anyway + return selectedSubId + } + + // Check if the selected SIM is still available + val isSimAvailable = availableSims.any { it.subscriptionId == selectedSubId } + + return if (isSimAvailable) { + // Selected SIM still exists, use it + selectedSubId + } else { + // Selected SIM was removed, fall back to default and reset preference + try { + preferencesRepository.setSelectedSubscriptionId(-1) + } catch (e: Exception) { + // Silent catch + } + SubscriptionManager.getDefaultDataSubscriptionId() + } + } +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt new file mode 100644 index 0000000..fc58e44 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt @@ -0,0 +1,593 @@ +package com.supernova.networkswitch.presentation.ui.activity + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.supernova.networkswitch.BuildConfig +import com.supernova.networkswitch.R +import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme +import kotlinx.coroutines.launch + +class AboutActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + NetworkSwitchTheme { + AboutScreen( + onBackClick = { finish() } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AboutScreen( + onBackClick: () -> Unit +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("About") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // App Info Header + AppInfoHeader() + + // App Version + AppVersionCard( + onCopyVersion = { + copyToClipboard( + context = context, + label = "App Version", + text = "NetworkSwitch v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + ) + scope.launch { + snackbarHostState.showSnackbar("Version copied to clipboard") + } + } + ) + + // Author Card + AuthorCard() + + // Source Code Card + SourceCodeCard() + + // Open Source Licenses + OpenSourceLicensesCard() + + // Device Info (Expandable) + DeviceInfoCard( + onCopyDeviceInfo = { + val deviceInfo = buildDeviceInfoString() + copyToClipboard( + context = context, + label = "Device Info", + text = deviceInfo + ) + scope.launch { + snackbarHostState.showSnackbar("Device info copied to clipboard") + } + } + ) + } + } +} + +@Composable +private fun AppInfoHeader() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + color = MaterialTheme.colorScheme.primary + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_5g_big), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "NetworkSwitch", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "One-Tap network switching", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +private fun AppVersionCard( + onCopyVersion: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCopyVersion) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Version", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onCopyVersion) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy version", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun AuthorCard() { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = "Developer", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Ameya Unchagaonkar", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/aunchagaonkar")) + ) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "github.com/aunchagaonkar", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open GitHub profile", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +private fun SourceCodeCard() { + val context = LocalContext.current + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/aunchagaonkar/NetworkSwitch")) + ) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Source Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "View on GitHub", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open source code", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun OpenSourceLicensesCard() { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Open Source Licenses", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp)) + + LicenseItem( + title = "Shizuku", + license = "Apache License 2.0", + url = "https://github.com/RikkaApps/Shizuku" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "libsu", + license = "Apache License 2.0", + url = "https://github.com/topjohnwu/libsu" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Android Jetpack", + license = "Apache License 2.0", + url = "https://android.googlesource.com/platform/frameworks/support" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Kotlin", + license = "Apache License 2.0", + url = "https://github.com/JetBrains/kotlin" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Hilt", + license = "Apache License 2.0", + url = "https://github.com/google/dagger" + ) + } + } + } + } +} + +@Composable +private fun LicenseItem( + title: String, + license: String, + url: String +) { + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open link", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + Text( + text = license, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DeviceInfoCard( + onCopyDeviceInfo: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Device Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp)) + + DeviceInfoRow("Device", Build.DEVICE) + DeviceInfoRow("Model", Build.MODEL) + DeviceInfoRow("Manufacturer", Build.MANUFACTURER) + DeviceInfoRow("Brand", Build.BRAND) + DeviceInfoRow("Android Version", Build.VERSION.RELEASE) + DeviceInfoRow("SDK Level", Build.VERSION.SDK_INT.toString()) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onCopyDeviceInfo, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Copy Device Info") + } + } + } + } + } +} + +@Composable +private fun DeviceInfoRow( + label: String, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + } +} + +private fun copyToClipboard(context: Context, label: String, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) +} + +private fun buildDeviceInfoString(): String { + return """ + Device: ${Build.DEVICE} + Model: ${Build.MODEL} + Manufacturer: ${Build.MANUFACTURER} + Brand: ${Build.BRAND} + Android Version: ${Build.VERSION.RELEASE} + SDK Level: ${Build.VERSION.SDK_INT} + App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + """.trimIndent() +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt index f7d08a7..dfb3886 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt @@ -1,10 +1,15 @@ package com.supernova.networkswitch.presentation.ui.activity +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -13,21 +18,27 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme import com.supernova.networkswitch.presentation.viewmodel.SettingsViewModel import dagger.hilt.android.AndroidEntryPoint +import com.supernova.networkswitch.presentation.ui.activity.AboutActivity @AndroidEntryPoint class SettingsActivity : ComponentActivity() { @@ -55,6 +66,71 @@ private fun SettingsScreen( onBackClick: () -> Unit ) { val controlMethod by viewModel.controlMethod.collectAsState() + val availableSims by viewModel.availableSims.collectAsState() + val selectedSubscriptionId by viewModel.selectedSubscriptionId.collectAsState() + val isLoadingSims by viewModel.isLoadingSims.collectAsState() + val simError by viewModel.simError.collectAsState() + + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + // Show error message in snackbar when error occurs + LaunchedEffect(simError) { + simError?.let { error -> + val result = snackbarHostState.showSnackbar( + message = error, + duration = SnackbarDuration.Long + ) + // Clear the error only after the snackbar is dismissed + if (result == SnackbarResult.Dismissed || result == SnackbarResult.ActionPerformed) { + viewModel.clearSimError() + } + } + } + var hasPhoneStatePermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + ) + } + + var showPermissionRationaleDialog by remember { mutableStateOf(false) } + + // Permission launcher + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasPhoneStatePermission = isGranted + if (isGranted) { + // Refresh SIM list after permission is granted + viewModel.refreshAvailableSims() + } + } + + // Extracted permission request logic + fun requestPhoneStatePermission() { + if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + showPermissionRationaleDialog = true + } + } + + // Show permission rationale dialog on first composition if permission not granted + LaunchedEffect(Unit) { + requestPhoneStatePermission() + } + + // Permission Rationale Dialog + if (showPermissionRationaleDialog) { + PermissionRationaleDialog( + onDismiss = { showPermissionRationaleDialog = false }, + onConfirm = { + showPermissionRationaleDialog = false + permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + } + ) + } Scaffold( topBar = { @@ -69,7 +145,8 @@ private fun SettingsScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> Column( modifier = Modifier @@ -88,8 +165,209 @@ private fun SettingsScreen( onRetryClick = { viewModel.retryCompatibilityCheck() } ) - // About Section - AboutCard() + // SIM Card Selection + // Show if multiple SIMs detected OR if permission not granted (to show info card) + if (availableSims.size > 1) { + SimSelectionCard( + availableSims = availableSims, + selectedSubscriptionId = selectedSubscriptionId, + isLoading = isLoadingSims, + onSimSelected = { viewModel.selectSim(it) }, + onRefresh = { viewModel.refreshAvailableSims() } + ) + } else if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Show permission info card + PermissionInfoCard( + onRequestPermission = { + requestPhoneStatePermission() + } + ) + } + + // Permissions Card + PermissionsCard( + hasPhoneStatePermission = hasPhoneStatePermission, + onRequestPermission = { + requestPhoneStatePermission() + } + ) + + // About Section - Button to navigate to About Activity + AboutNavigationCard( + onNavigateToAbout = { + context.startActivity(Intent(context, AboutActivity::class.java)) + } + ) + } + } +} + +@Composable +private fun PermissionRationaleDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "Multi-SIM Support Permission", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column { + Text( + text = "NetworkSwitch needs access to read your phone state to detect and manage multiple SIM cards on your device.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "This permission allows the app to:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "• Identify available SIM cards", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Display SIM card names and operators", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Allow you to choose which SIM to control", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Your privacy is important. This permission is only used to identify SIM cards and is never used to access your calls, messages, or contacts.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text("Grant Permission") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Not Now") + } + } + ) +} + +@Composable +private fun PermissionsCard( + hasPhoneStatePermission: Boolean, + onRequestPermission: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Permissions", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Manage app permissions to enable all features", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Phone State Permission Item + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !hasPhoneStatePermission) { + onRequestPermission() + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (hasPhoneStatePermission) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (hasPhoneStatePermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Phone State", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = if (hasPhoneStatePermission) "Granted - Multi-SIM support enabled" else "Not granted - Tap to request", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun AboutNavigationCard( + onNavigateToAbout: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onNavigateToAbout) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "About", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "App information, licenses, and more", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate to About", + modifier = Modifier.size(24.dp) + ) } } } @@ -271,7 +549,15 @@ private fun ControlMethodCard( } @Composable -private fun AboutCard() { +private fun SimSelectionCard( + availableSims: List, + selectedSubscriptionId: Int, + isLoading: Boolean, + onSimSelected: (Int) -> Unit, + onRefresh: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Card( modifier = Modifier.fillMaxWidth() ) { @@ -280,90 +566,202 @@ private fun AboutCard() { .fillMaxWidth() .padding(16.dp) ) { - Text( - text = "About", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Source Code", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "SIM Card Selection", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + if (!isLoading) { + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh SIM list" + ) + } + } + } Spacer(modifier = Modifier.height(8.dp)) - LinkItem( - title = "NetworkSwitch", - subtitle = "https://github.com/aunchagaonkar/NetworkSwitch", - link = "https://github.com/aunchagaonkar/NetworkSwitch" - ) - - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Open Source Licenses", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - LinkItem( - title = "Shizuku", - subtitle = "Apache License 2.0\nhttps://github.com/RikkaApps/Shizuku", - link = "https://github.com/RikkaApps/Shizuku" - ) - - LinkItem( - title = "libsu", - subtitle = "Apache License 2.0\nhttps://github.com/topjohnwu/libsu", - link = "https://github.com/topjohnwu/libsu" + text = "Choose which SIM card to use for network switching. The app will only change network settings for the selected SIM.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - LinkItem( - title = "Android Jetpack", - subtitle = "Apache License 2.0\nhttps://android.googlesource.com/platform/frameworks/support", - link = "https://android.googlesource.com/platform/frameworks/support" - ) + Spacer(modifier = Modifier.height(16.dp)) - LinkItem( - title = "Kotlin", - subtitle = "Apache License 2.0\nhttps://github.com/JetBrains/kotlin", - link = "https://github.com/JetBrains/kotlin" - ) + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // Dropdown Menu + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = getSelectedSimDisplayName(availableSims, selectedSubscriptionId), + onValueChange = {}, + readOnly = true, + label = { Text("Selected SIM") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + // Option for "Auto/Default" + DropdownMenuItem( + text = { + Column { + Text( + text = "Auto (System Default)", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Let the system choose", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onSimSelected(-1) + expanded = false + }, + leadingIcon = { + if (selectedSubscriptionId == -1) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + + // Individual SIM options + availableSims.forEach { sim -> + DropdownMenuItem( + text = { + Column { + Text( + text = sim.displayName, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Subscription ID: ${sim.subscriptionId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onSimSelected(sim.subscriptionId) + expanded = false + }, + leadingIcon = { + if (sim.subscriptionId == selectedSubscriptionId) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + } + } + } + } } } } +/** + * Helper function to get the display name for the selected SIM + */ +private fun getSelectedSimDisplayName( + availableSims: List, + selectedSubscriptionId: Int +): String { + if (selectedSubscriptionId == -1) { + return "Auto (System Default)" + } + return availableSims.find { it.subscriptionId == selectedSubscriptionId }?.displayName + ?: "Unknown SIM" +} + @Composable -private fun LinkItem( - title: String, - subtitle: String, - link: String +private fun PermissionInfoCard( + onRequestPermission: () -> Unit ) { - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) - } - .padding(vertical = 8.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Multi-SIM Support", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "To detect and manage multiple SIM cards, this app needs permission to read your phone state. This permission is only used to identify available SIM cards.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRequestPermission, + modifier = Modifier.fillMaxWidth() + ) { + Text("Grant Permission") + } + } } } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt index f59b820..40ec347 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt @@ -1,6 +1,5 @@ package com.supernova.networkswitch.presentation.viewmodel -import android.telephony.SubscriptionManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.compose.runtime.mutableStateOf @@ -12,6 +11,7 @@ import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.CheckCompatibilityUseCase import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase +import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.UpdateControlMethodUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase @@ -25,6 +25,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel class MainViewModel @Inject constructor( private val checkCompatibilityUseCase: CheckCompatibilityUseCase, private val getCurrentNetworkModeUseCase: GetCurrentNetworkModeUseCase, + private val getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase, private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, @@ -138,7 +139,8 @@ class MainViewModel @Inject constructor( isLoading = true viewModelScope.launch { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() + // Use the user's selected subscription ID (or default if "Auto" selected) + val subId = getEffectiveSubscriptionIdUseCase() toggleNetworkModeUseCase(subId) .onSuccess { newMode -> @@ -166,7 +168,8 @@ class MainViewModel @Inject constructor( */ private fun refreshNetworkState() { viewModelScope.launch { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() + // Use the user's selected subscription ID (or default if "Auto" selected) + val subId = getEffectiveSubscriptionIdUseCase() getCurrentNetworkModeUseCase(subId) .onSuccess { mode -> diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt index 147885b..0a3172b 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt @@ -7,10 +7,17 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.usecase.GetAvailableSimsUseCase +import com.supernova.networkswitch.domain.usecase.GetSelectedSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.SetSelectedSubscriptionIdUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.async @@ -22,7 +29,10 @@ import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val preferencesRepository: PreferencesRepository, - private val networkControlRepository: NetworkControlRepository + private val networkControlRepository: NetworkControlRepository, + private val getAvailableSimsUseCase: GetAvailableSimsUseCase, + private val getSelectedSubscriptionIdUseCase: GetSelectedSubscriptionIdUseCase, + private val setSelectedSubscriptionIdUseCase: SetSelectedSubscriptionIdUseCase ) : ViewModel() { val controlMethod: StateFlow = preferencesRepository.observeControlMethod() @@ -39,8 +49,30 @@ class SettingsViewModel @Inject constructor( var shizukuCompatibility by mutableStateOf(CompatibilityState.Pending) private set + // Available SIM cards in the device + private val _availableSims = MutableStateFlow>(emptyList()) + val availableSims: StateFlow> = _availableSims.asStateFlow() + + // Currently selected subscription ID + val selectedSubscriptionId: StateFlow = preferencesRepository.observeSelectedSubscriptionId() + .stateIn( + scope = viewModelScope, + started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), + initialValue = -1 // Default: use system default + ) + + // Loading state for SIM detection + private val _isLoadingSims = MutableStateFlow(false) + val isLoadingSims: StateFlow = _isLoadingSims.asStateFlow() + + // Error state for SIM operations + private val _simError = MutableStateFlow(null) + val simError: StateFlow = _simError.asStateFlow() + init { checkAllCompatibility() + loadAvailableSims() + observeSelectedSimValidity() } fun updateControlMethod(method: ControlMethod) { @@ -66,4 +98,131 @@ class SettingsViewModel @Inject constructor( shizukuCompatibility = shizukuResult.await() } } + + /** + * Load all available SIM cards in the device + */ + private fun loadAvailableSims() { + viewModelScope.launch { + _isLoadingSims.value = true + _simError.value = null + try { + val result = getAvailableSimsUseCase() + if (result.isSuccess) { + val sims = result.getOrNull() ?: emptyList() + _availableSims.value = sims + + // Check if previously selected SIM is still available + validateSelectedSim(sims) + } else { + _availableSims.value = emptyList() + _simError.value = "Failed to detect SIM cards" + } + } catch (e: Exception) { + _availableSims.value = emptyList() + _simError.value = "Error: ${e.message}" + } finally { + _isLoadingSims.value = false + } + } + } + + /** + * Validate that the currently selected SIM is still available + * Reset to Auto if the selected SIM was removed + */ + private suspend fun validateSelectedSim(availableSims: List) { + val currentSelection = selectedSubscriptionId.value + + // Skip validation if Auto mode (-1) or if no selection + if (currentSelection == -1) return + + // Check if the selected SIM is still in the available list + val isStillAvailable = availableSims.any { it.subscriptionId == currentSelection } + + if (!isStillAvailable) { + // Selected SIM was removed, reset to Auto mode + try { + setSelectedSubscriptionIdUseCase(-1) + _simError.value = "Previously selected SIM was removed. Switched to Auto mode." + } catch (e: Exception) { + _simError.value = "Failed to update SIM selection" + } + } + } + + /** + * Observe selected SIM validity and auto-correct if removed + */ + // Mutex to prevent redundant validation calls in a thread-safe way + private val simValidationMutex = kotlinx.coroutines.sync.Mutex() + + private fun observeSelectedSimValidity() { + viewModelScope.launch { + selectedSubscriptionId.collectLatest { subId -> + if (subId != -1 && _availableSims.value.isNotEmpty()) { + val isValid = _availableSims.value.any { it.subscriptionId == subId } + if (!isValid) { + simValidationMutex.lock() + try { + validateSelectedSim(_availableSims.value) + } finally { + simValidationMutex.unlock() + } + } + } + } + } + } + + /** + * Refresh the list of available SIM cards + * Useful when permission is granted or SIM cards are changed + */ + fun refreshAvailableSims() { + loadAvailableSims() + } + + /** + * Select a specific SIM card for network operations + * @param subscriptionId The subscription ID of the SIM to select, or -1 for default + */ + fun selectSim(subscriptionId: Int) { + viewModelScope.launch { + try { + // Validate the selection before saving + if (subscriptionId != -1) { + val isValid = _availableSims.value.any { it.subscriptionId == subscriptionId } + if (!isValid) { + _simError.value = "Selected SIM is not available" + return@launch + } + } + + setSelectedSubscriptionIdUseCase(subscriptionId) + _simError.value = null // Clear any previous errors + } catch (e: Exception) { + _simError.value = "Failed to save SIM selection: ${e.message}" + } + } + } + + /** + * Clear any SIM-related error messages + */ + fun clearSimError() { + _simError.value = null + } + + /** + * Get the currently selected SIM info object + * Returns null if no specific SIM is selected or if the selected SIM is not available + */ + fun getSelectedSimInfo(): SimInfo? { + val currentSubscriptionId = selectedSubscriptionId.value + if (currentSubscriptionId == -1) { + return null // No specific SIM selected + } + return _availableSims.value.find { it.subscriptionId == currentSubscriptionId } + } } diff --git a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt index 0fa943d..bd79ade 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -2,11 +2,11 @@ package com.supernova.networkswitch.service import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import android.telephony.SubscriptionManager import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase +import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -20,6 +20,9 @@ class NetworkTileService : TileService() { @Inject lateinit var getCurrentNetworkModeUseCase: GetCurrentNetworkModeUseCase + @Inject + lateinit var getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase + @Inject lateinit var toggleNetworkModeUseCase: ToggleNetworkModeUseCase @@ -57,10 +60,11 @@ class NetworkTileService : TileService() { override fun onClick() { super.onClick() - val subId = SubscriptionManager.getDefaultDataSubscriptionId() - serviceScope.launch { try { + // Use the user's selected subscription ID (or default if "Auto" selected) + val subId = getEffectiveSubscriptionIdUseCase() + toggleNetworkModeUseCase(subId) .onSuccess { newMode -> currentNetworkMode = newMode @@ -78,9 +82,10 @@ class NetworkTileService : TileService() { } private suspend fun refreshNetworkState() { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() - try { + // Use the user's selected subscription ID (or default if "Auto" selected) + val subId = getEffectiveSubscriptionIdUseCase() + getCurrentNetworkModeUseCase(subId) .onSuccess { networkMode -> currentNetworkMode = networkMode diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt new file mode 100644 index 0000000..9ec9d18 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt @@ -0,0 +1,118 @@ +package com.supernova.networkswitch.domain.usecase + +import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.repository.SimRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for GetAvailableSimsUseCase + */ +class GetAvailableSimsUseCaseTest { + + private lateinit var simRepository: SimRepository + private lateinit var useCase: GetAvailableSimsUseCase + + @Before + fun setUp() { + simRepository = mockk() + useCase = GetAvailableSimsUseCase(simRepository) + } + + @Test + fun `should return success with list of SIMs when repository returns data`() = runTest { + // Given + val simList = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1 (Slot 1)"), + SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2 (Slot 2)") + ) + coEvery { simRepository.getAvailableSimCards() } returns simList + + // When + val result = useCase() + + // Then + assertTrue(result.isSuccess) + assertEquals(simList, result.getOrNull()) + assertEquals(2, result.getOrNull()?.size) + } + + @Test + fun `should return success with empty list when no SIMs available`() = runTest { + // Given + coEvery { simRepository.getAvailableSimCards() } returns emptyList() + + // When + val result = useCase() + + // Then + assertTrue(result.isSuccess) + assertEquals(emptyList(), result.getOrNull()) + } + + @Test + fun `should return success with empty list when permission not granted`() = runTest { + // Given (permission denied case returns empty list) + coEvery { simRepository.getAvailableSimCards() } returns emptyList() + + // When + val result = useCase() + + // Then + assertTrue(result.isSuccess) + assertTrue(result.getOrNull()?.isEmpty() == true) + } + + @Test + fun `should return failure when repository throws exception`() = runTest { + // Given + val exception = RuntimeException("Failed to access SubscriptionManager") + coEvery { simRepository.getAvailableSimCards() } throws exception + + // When + val result = useCase() + + // Then + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `should handle single SIM device`() = runTest { + // Given + val singleSim = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "My Carrier (Slot 1)") + ) + coEvery { simRepository.getAvailableSimCards() } returns singleSim + + // When + val result = useCase() + + // Then + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.size) + assertEquals(1, result.getOrNull()?.first()?.subscriptionId) + } + + @Test + fun `should handle triple SIM device`() = runTest { + // Given + val tripleSim = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1 (Slot 1)"), + SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2 (Slot 2)"), + SimInfo(subscriptionId = 3, simSlotIndex = 2, displayName = "SIM 3 (Slot 3)") + ) + coEvery { simRepository.getAvailableSimCards() } returns tripleSim + + // When + val result = useCase() + + // Then + assertTrue(result.isSuccess) + assertEquals(3, result.getOrNull()?.size) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt new file mode 100644 index 0000000..0cefb8a --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt @@ -0,0 +1,153 @@ +package com.supernova.networkswitch.domain.usecase + +import android.telephony.SubscriptionManager +import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.repository.SimRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for GetEffectiveSubscriptionIdUseCase + */ +class GetEffectiveSubscriptionIdUseCaseTest { + + private lateinit var preferencesRepository: PreferencesRepository + private lateinit var simRepository: SimRepository + private lateinit var useCase: GetEffectiveSubscriptionIdUseCase + + private val defaultSubId = 100 + + @Before + fun setUp() { + preferencesRepository = mockk() + simRepository = mockk() + useCase = GetEffectiveSubscriptionIdUseCase(preferencesRepository, simRepository) + + // Mock SubscriptionManager static method + mockkStatic(SubscriptionManager::class) + every { SubscriptionManager.getDefaultDataSubscriptionId() } returns defaultSubId + } + + @After + fun tearDown() { + unmockkStatic(SubscriptionManager::class) + } + + @Test + fun `should return default subscription ID when selected is -1 (Auto mode)`() = runTest { + // Given + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns -1 + + // When + val result = useCase() + + // Then + assertEquals(defaultSubId, result) + } + + @Test + fun `should return specific subscription ID when selected by user and SIM exists`() = runTest { + // Given + val selectedSubId = 42 + val availableSims = listOf( + SimInfo(subscriptionId = 42, simSlotIndex = 0, displayName = "SIM 1") + ) + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId + coEvery { simRepository.getAvailableSimCards() } returns availableSims + + // When + val result = useCase() + + // Then + assertEquals(selectedSubId, result) + } + + @Test + fun `should fallback to default when selected SIM was removed`() = runTest { + // Given + val selectedSubId = 42 + val availableSims = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1") + ) + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId + coEvery { simRepository.getAvailableSimCards() } returns availableSims + coEvery { preferencesRepository.setSelectedSubscriptionId(-1) } returns Unit + + // When + val result = useCase() + + // Then + assertEquals(defaultSubId, result) + coVerify { preferencesRepository.setSelectedSubscriptionId(-1) } + } + + @Test + fun `should use selected ID when cannot check SIM availability`() = runTest { + // Given + val selectedSubId = 42 + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId + coEvery { simRepository.getAvailableSimCards() } throws SecurityException("Permission denied") + + // When + val result = useCase() + + // Then + assertEquals(selectedSubId, result) + } + + @Test + fun `should return subscription ID 1 when user selected SIM 1 and it exists`() = runTest { + // Given + val availableSims = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1"), + SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2") + ) + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns 1 + coEvery { simRepository.getAvailableSimCards() } returns availableSims + + // When + val result = useCase() + + // Then + assertEquals(1, result) + } + + @Test + fun `should return subscription ID 2 when user selected SIM 2 and it exists`() = runTest { + // Given + val availableSims = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1"), + SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2") + ) + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns 2 + coEvery { simRepository.getAvailableSimCards() } returns availableSims + + // When + val result = useCase() + + // Then + assertEquals(2, result) + } + + @Test + fun `should handle invalid subscription ID of INVALID_SUBSCRIPTION_ID constant`() = runTest { + // Given (SubscriptionManager.INVALID_SUBSCRIPTION_ID = -1) + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns SubscriptionManager.INVALID_SUBSCRIPTION_ID + + // When + val result = useCase() + + // Then + assertEquals(defaultSubId, result) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/SubscriptionIdPreferencesUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/SubscriptionIdPreferencesUseCaseTest.kt new file mode 100644 index 0000000..4ae0d6a --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/SubscriptionIdPreferencesUseCaseTest.kt @@ -0,0 +1,94 @@ +package com.supernova.networkswitch.domain.usecase + +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for subscription ID preference use cases + */ +class SubscriptionIdPreferencesUseCaseTest { + + private lateinit var preferencesRepository: PreferencesRepository + private lateinit var getUseCase: GetSelectedSubscriptionIdUseCase + private lateinit var setUseCase: SetSelectedSubscriptionIdUseCase + + @Before + fun setUp() { + preferencesRepository = mockk() + getUseCase = GetSelectedSubscriptionIdUseCase(preferencesRepository) + setUseCase = SetSelectedSubscriptionIdUseCase(preferencesRepository) + } + + @Test + fun `GetSelectedSubscriptionIdUseCase should return saved subscription ID`() = runTest { + // Given + val expectedSubId = 1 + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns expectedSubId + + // When + val result = getUseCase() + + // Then + assertEquals(expectedSubId, result) + } + + @Test + fun `GetSelectedSubscriptionIdUseCase should return -1 when no selection made`() = runTest { + // Given + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns -1 + + // When + val result = getUseCase() + + // Then + assertEquals(-1, result) + } + + @Test + fun `SetSelectedSubscriptionIdUseCase should save subscription ID`() = runTest { + // Given + val subIdToSave = 2 + coEvery { preferencesRepository.setSelectedSubscriptionId(subIdToSave) } returns Unit + + // When + setUseCase(subIdToSave) + + // Then + coVerify { preferencesRepository.setSelectedSubscriptionId(subIdToSave) } + } + + @Test + fun `SetSelectedSubscriptionIdUseCase should save -1 for Auto mode`() = runTest { + // Given + coEvery { preferencesRepository.setSelectedSubscriptionId(-1) } returns Unit + + // When + setUseCase(-1) + + // Then + coVerify { preferencesRepository.setSelectedSubscriptionId(-1) } + } + + @Test + fun `should handle complete flow of saving and retrieving subscription ID`() = runTest { + // Given + val subId = 1 + coEvery { preferencesRepository.setSelectedSubscriptionId(subId) } returns Unit + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns subId + + // When + setUseCase(subId) + val retrieved = getUseCase() + + // Then + assertEquals(subId, retrieved) + coVerify { preferencesRepository.setSelectedSubscriptionId(subId) } + coVerify { preferencesRepository.getSelectedSubscriptionId() } + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt index b94b532..b5b8463 100644 --- a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt @@ -2,8 +2,12 @@ package com.supernova.networkswitch.presentation.viewmodel import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository +import com.supernova.networkswitch.domain.usecase.GetAvailableSimsUseCase +import com.supernova.networkswitch.domain.usecase.GetSelectedSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.SetSelectedSubscriptionIdUseCase import com.supernova.networkswitch.util.CoroutineTestRule import io.mockk.coEvery import io.mockk.coVerify @@ -24,6 +28,9 @@ class SettingsViewModelTest { private lateinit var preferencesRepository: PreferencesRepository private lateinit var networkControlRepository: NetworkControlRepository + private lateinit var getAvailableSimsUseCase: GetAvailableSimsUseCase + private lateinit var getSelectedSubscriptionIdUseCase: GetSelectedSubscriptionIdUseCase + private lateinit var setSelectedSubscriptionIdUseCase: SetSelectedSubscriptionIdUseCase private lateinit var viewModel: SettingsViewModel @@ -31,14 +38,23 @@ class SettingsViewModelTest { fun setUp() { preferencesRepository = mockk(relaxed = true) networkControlRepository = mockk(relaxed = true) + getAvailableSimsUseCase = mockk(relaxed = true) + getSelectedSubscriptionIdUseCase = mockk(relaxed = true) + setSelectedSubscriptionIdUseCase = mockk(relaxed = true) coEvery { preferencesRepository.observeControlMethod() } returns flowOf(ControlMethod.SHIZUKU) + coEvery { preferencesRepository.observeSelectedSubscriptionId() } returns flowOf(-1) + coEvery { getAvailableSimsUseCase() } returns Result.success(emptyList()) + coEvery { getSelectedSubscriptionIdUseCase() } returns -1 } private fun createViewModel() { viewModel = SettingsViewModel( preferencesRepository, - networkControlRepository + networkControlRepository, + getAvailableSimsUseCase, + getSelectedSubscriptionIdUseCase, + setSelectedSubscriptionIdUseCase ) } @@ -77,4 +93,37 @@ class SettingsViewModelTest { coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } } + + @Test + fun `clearSimError resets simError state`() = runTest { + createViewModel() + // Manually set error by trying to select invalid SIM + viewModel.selectSim(999) // Invalid SIM ID + viewModel.clearSimError() + assertEquals(null, viewModel.simError.value) + } + + @Test + fun `selectSim sets error for invalid SIM`() = runTest { + // Mock available SIMs to contain only simId = 1 + coEvery { getAvailableSimsUseCase() } returns Result.success( + listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1")) + ) + createViewModel() + val invalidSimId = 999 + viewModel.selectSim(invalidSimId) + assertEquals("Selected SIM is not available", viewModel.simError.value) + } + + @Test + fun `selectSim clears error for valid SIM`() = runTest { + val validSimId = 1 + // Mock available SIMs to contain simId = 1 + coEvery { getAvailableSimsUseCase() } returns Result.success( + listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1")) + ) + createViewModel() + viewModel.selectSim(validSimId) + assertEquals(null, viewModel.simError.value) + } }