From 5990024e31e4e79ed4cc52f6136c144c1467fbf9 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 19 Oct 2025 19:23:13 +0530 Subject: [PATCH 01/22] feat: Implement SIM card management with selection and preferences --- app/src/main/AndroidManifest.xml | 3 + .../repository/PreferencesRepositoryImpl.kt | 12 ++ .../data/repository/SimRepositoryImpl.kt | 113 +++++++++++ .../data/source/PreferencesDataSource.kt | 33 ++++ .../supernova/networkswitch/di/DataModule.kt | 8 + .../domain/model/NetworkSwitchModels.kt | 9 + .../domain/repository/Repositories.kt | 29 +++ .../domain/usecase/NetworkUseCases.kt | 40 ++++ .../ui/activity/SettingsActivity.kt | 179 ++++++++++++++++++ .../viewmodel/SettingsViewModel.kt | 83 +++++++- 10 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3552c09..2fe9f7a 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..7b17444 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 @@ -3,9 +3,11 @@ package com.supernova.networkswitch.domain.usecase 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 +94,41 @@ 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) + } +} 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..a634c1e 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 @@ -17,6 +17,7 @@ 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.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,6 +56,9 @@ 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() Scaffold( topBar = { @@ -88,6 +92,17 @@ private fun SettingsScreen( onRetryClick = { viewModel.retryCompatibilityCheck() } ) + // SIM Card Selection (only show if multiple SIMs detected) + if (availableSims.size > 1) { + SimSelectionCard( + availableSims = availableSims, + selectedSubscriptionId = selectedSubscriptionId, + isLoading = isLoadingSims, + onSimSelected = { viewModel.selectSim(it) }, + onRefresh = { viewModel.refreshAvailableSims() } + ) + } + // About Section AboutCard() } @@ -270,6 +285,170 @@ private fun ControlMethodCard( } } +@Composable +private fun SimSelectionCard( + availableSims: List, + selectedSubscriptionId: Int, + isLoading: Boolean, + onSimSelected: (Int) -> Unit, + onRefresh: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + 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)) + + Text( + 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 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + 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 AboutCard() { Card( 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..4800935 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,16 @@ 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.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.async @@ -22,7 +28,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 +48,25 @@ 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() + init { checkAllCompatibility() + loadAvailableSims() } fun updateControlMethod(method: ControlMethod) { @@ -66,4 +92,59 @@ class SettingsViewModel @Inject constructor( shizukuCompatibility = shizukuResult.await() } } + + /** + * Load all available SIM cards in the device + */ + private fun loadAvailableSims() { + viewModelScope.launch { + _isLoadingSims.value = true + try { + val result = getAvailableSimsUseCase() + if (result.isSuccess) { + _availableSims.value = result.getOrNull() ?: emptyList() + } else { + _availableSims.value = emptyList() + } + } catch (e: Exception) { + _availableSims.value = emptyList() + } finally { + _isLoadingSims.value = false + } + } + } + + /** + * 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 { + setSelectedSubscriptionIdUseCase(subscriptionId) + } catch (e: Exception) { + // Handle error if needed (could show toast or snackbar) + } + } + } + + /** + * 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 } + } } From 2880da11995e5e239e47359a5beda0a6684b81af Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 19 Oct 2025 20:19:58 +0530 Subject: [PATCH 02/22] feat: Implement effective subscription ID handling for network operations --- .../NetworkControlRepositoryImpl.kt | 21 +++- .../domain/usecase/NetworkUseCases.kt | 20 ++++ .../ui/activity/SettingsActivity.kt | 98 ++++++++++++++++++- .../presentation/viewmodel/MainViewModel.kt | 9 +- .../service/NetworkTileService.kt | 15 ++- 5 files changed, 153 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index 3881e56..c8f3385 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.supernova.networkswitch.data.repository +import android.telephony.SubscriptionManager import com.supernova.networkswitch.data.source.NetworkControlDataSource import com.supernova.networkswitch.data.source.RootNetworkControlDataSource import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource @@ -23,7 +24,8 @@ class NetworkControlRepositoryImpl @Inject constructor( override suspend fun checkCompatibility(method: ControlMethod): CompatibilityState { val dataSource = getDataSource(method) - val subId = android.telephony.SubscriptionManager.getDefaultDataSubscriptionId() + // Use selected subscription ID for compatibility check + val subId = getEffectiveSubscriptionId() return dataSource.checkCompatibility(subId) } @@ -31,6 +33,7 @@ class NetworkControlRepositoryImpl @Inject constructor( return try { val method = preferencesRepository.getControlMethod() val dataSource = getDataSource(method) + // Use the provided subId (which should come from getEffectiveSubscriptionId in callers) dataSource.getCurrentNetworkMode(subId) } catch (e: Exception) { null @@ -41,6 +44,7 @@ class NetworkControlRepositoryImpl @Inject constructor( return try { val method = preferencesRepository.getControlMethod() val dataSource = getDataSource(method) + // Use the provided subId (which should come from getEffectiveSubscriptionId in callers) dataSource.setNetworkMode(subId, mode) Result.success(Unit) } catch (e: Exception) { @@ -55,6 +59,21 @@ class NetworkControlRepositoryImpl @Inject constructor( rootDataSource.resetConnection() shizukuDataSource.resetConnection() } + + /** + * Get the effective subscription ID to use for network operations + * Returns the user's selected subscription ID from preferences, or the default if -1 + */ + private suspend fun getEffectiveSubscriptionId(): Int { + val selectedSubId = preferencesRepository.getSelectedSubscriptionId() + return if (selectedSubId == -1) { + // User selected "Auto" - use system default + SubscriptionManager.getDefaultDataSubscriptionId() + } else { + // User selected a specific SIM + selectedSubId + } + } private fun getDataSource(method: ControlMethod): NetworkControlDataSource { return when (method) { 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 7b17444..dc3a7e6 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,5 +1,6 @@ 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 @@ -132,3 +133,22 @@ class SetSelectedSubscriptionIdUseCase @Inject constructor( 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 + */ +class GetEffectiveSubscriptionIdUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(): Int { + val selectedSubId = preferencesRepository.getSelectedSubscriptionId() + return if (selectedSubId == -1) { + // User selected "Auto" - use system default + SubscriptionManager.getDefaultDataSubscriptionId() + } else { + // User selected a specific SIM + selectedSubId + } + } +} 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 a634c1e..1ae1224 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.* @@ -24,6 +29,7 @@ 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 @@ -60,6 +66,34 @@ private fun SettingsScreen( val selectedSubscriptionId by viewModel.selectedSubscriptionId.collectAsState() val isLoadingSims by viewModel.isLoadingSims.collectAsState() + val context = LocalContext.current + var hasPhoneStatePermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + ) + } + + // Permission launcher + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasPhoneStatePermission = isGranted + if (isGranted) { + // Refresh SIM list after permission is granted + viewModel.refreshAvailableSims() + } + } + + // Request permission on first composition if not granted + LaunchedEffect(Unit) { + if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + } + } + Scaffold( topBar = { TopAppBar( @@ -92,7 +126,8 @@ private fun SettingsScreen( onRetryClick = { viewModel.retryCompatibilityCheck() } ) - // SIM Card Selection (only show if multiple SIMs detected) + // SIM Card Selection + // Show if multiple SIMs detected OR if permission not granted (to show info card) if (availableSims.size > 1) { SimSelectionCard( availableSims = availableSims, @@ -101,6 +136,13 @@ private fun SettingsScreen( onSimSelected = { viewModel.selectSim(it) }, onRefresh = { viewModel.refreshAvailableSims() } ) + } else if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Show permission info card + PermissionInfoCard( + onRequestPermission = { + permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + } + ) } // About Section @@ -449,6 +491,60 @@ private fun getSelectedSimDisplayName( ?: "Unknown SIM" } +@Composable +private fun PermissionInfoCard( + onRequestPermission: () -> Unit +) { + 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") + } + } + } +} + @Composable private fun AboutCard() { Card( 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/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 From 56e076b1b7845399b06bf4b7c67097f07bc03005 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 19 Oct 2025 22:59:02 +0530 Subject: [PATCH 03/22] feat: Improve SIM selection handling with validation and error management --- .../domain/usecase/NetworkUseCases.kt | 36 ++++- .../ui/activity/SettingsActivity.kt | 21 ++- .../viewmodel/SettingsViewModel.kt | 82 +++++++++- .../usecase/GetAvailableSimsUseCaseTest.kt | 118 ++++++++++++++ .../GetEffectiveSubscriptionIdUseCaseTest.kt | 153 ++++++++++++++++++ .../SubscriptionIdPreferencesUseCaseTest.kt | 94 +++++++++++ .../viewmodel/SettingsViewModelTest.kt | 51 +++++- 7 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/usecase/SubscriptionIdPreferencesUseCaseTest.kt 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 dc3a7e6..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 @@ -137,18 +137,42 @@ class SetSelectedSubscriptionIdUseCase @Inject constructor( /** * 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 preferencesRepository: PreferencesRepository, + private val simRepository: SimRepository ) { suspend operator fun invoke(): Int { val selectedSubId = preferencesRepository.getSelectedSubscriptionId() - return if (selectedSubId == -1) { - // User selected "Auto" - use system default - SubscriptionManager.getDefaultDataSubscriptionId() - } else { - // User selected a specific SIM + + // 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/SettingsActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt index 1ae1224..e58ec3d 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 @@ -23,6 +23,8 @@ 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 @@ -65,8 +67,24 @@ private fun SettingsScreen( 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( @@ -107,7 +125,8 @@ private fun SettingsScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> Column( modifier = Modifier 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 4800935..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 @@ -17,6 +17,7 @@ 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 @@ -64,9 +65,14 @@ class SettingsViewModel @Inject constructor( 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) { @@ -99,21 +105,76 @@ class SettingsViewModel @Inject constructor( private fun loadAvailableSims() { viewModelScope.launch { _isLoadingSims.value = true + _simError.value = null try { val result = getAvailableSimsUseCase() if (result.isSuccess) { - _availableSims.value = result.getOrNull() ?: emptyList() + 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 @@ -129,13 +190,30 @@ class SettingsViewModel @Inject constructor( 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) { - // Handle error if needed (could show toast or snackbar) + _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 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) + } } From 725e115dacd130f6cffee9c4cc8083a329325957 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 20 Oct 2025 09:26:50 +0530 Subject: [PATCH 04/22] feat: Add AboutActivity and integrate navigation from SettingsActivity --- app/src/main/AndroidManifest.xml | 7 + .../presentation/ui/activity/AboutActivity.kt | 593 ++++++++++++++++++ .../ui/activity/SettingsActivity.kt | 314 ++++++---- 3 files changed, 809 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fe9f7a..b0f7307 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,13 @@ android:parentActivityName=".presentation.ui.activity.MainActivity" android:theme="@style/Theme.NetworkSwitch" /> + + 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 e58ec3d..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 @@ -18,6 +18,7 @@ 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 @@ -37,6 +38,7 @@ 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() { @@ -94,6 +96,8 @@ private fun SettingsScreen( ) } + var showPermissionRationaleDialog by remember { mutableStateOf(false) } + // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() @@ -104,13 +108,29 @@ private fun SettingsScreen( viewModel.refreshAvailableSims() } } - - // Request permission on first composition if not granted - LaunchedEffect(Unit) { + + // Extracted permission request logic + fun requestPhoneStatePermission() { if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + 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 = { @@ -159,13 +179,195 @@ private fun SettingsScreen( // Show permission info card PermissionInfoCard( onRequestPermission = { - permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + requestPhoneStatePermission() } ) } - // About Section - AboutCard() + // 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) + ) } } } @@ -563,101 +765,3 @@ private fun PermissionInfoCard( } } } - -@Composable -private fun AboutCard() { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .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 - ) - - 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" - ) - - LinkItem( - title = "Android Jetpack", - subtitle = "Apache License 2.0\nhttps://android.googlesource.com/platform/frameworks/support", - link = "https://android.googlesource.com/platform/frameworks/support" - ) - - LinkItem( - title = "Kotlin", - subtitle = "Apache License 2.0\nhttps://github.com/JetBrains/kotlin", - link = "https://github.com/JetBrains/kotlin" - ) - } - } -} - -@Composable -private fun LinkItem( - title: String, - subtitle: String, - link: String -) { - 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 - ) - } -} From 131533c1667246994ed7486e768b533d2afe6fb0 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Thu, 9 Jul 2026 00:09:23 +0530 Subject: [PATCH 05/22] feat: Add SIM Switch Quick Settings Tile for multi-SIM cycling - Shows current SIM name and status on tile --- app/src/main/AndroidManifest.xml | 11 + .../service/SimSwitchTileService.kt | 193 ++++++++++++++++++ app/src/main/res/drawable/ic_sim_card.xml | 18 ++ app/src/main/res/values/strings.xml | 3 + 4 files changed, 225 insertions(+) create mode 100644 app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt create mode 100644 app/src/main/res/drawable/ic_sim_card.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b0f7307..d291b7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,17 @@ + + + + + + = emptyList() + private var selectedSubId: Int = -1 + + override fun onStartListening() { + super.onStartListening() + listeningJob?.cancel() + listeningJob = serviceScope.launch { + try { + // Load available SIMs first + availableSims = try { + simRepository.getAvailableSimCards() + } catch (_: Exception) { + emptyList() + } + + // Then observe the selected subscription ID and update tile + preferencesRepository.observeSelectedSubscriptionId().collect { subId -> + selectedSubId = subId + withContext(Dispatchers.Main) { + refreshTileDisplay() + } + } + } catch (_: CancellationException) { + // Expected when job is cancelled + } catch (_: Exception) { + // Handle errors silently + } + } + } + + override fun onStopListening() { + super.onStopListening() + listeningJob?.cancel() + listeningJob = null + } + + override fun onClick() { + super.onClick() + + serviceScope.launch { + try { + // Check permission + if (!hasReadPhoneStatePermission()) { + withContext(Dispatchers.Main) { + refreshTileDisplay() + } + return@launch + } + + // Refresh available SIMs in case they changed + availableSims = try { + simRepository.getAvailableSimCards() + } catch (_: Exception) { + emptyList() + } + + // Single SIM or no SIMs — nothing to cycle + if (availableSims.size <= 1) { + withContext(Dispatchers.Main) { + refreshTileDisplay() + } + return@launch + } + + // Cycle to the next SIM: Auto → SIM 1 → SIM 2 → ... → Auto + val currentSubId = preferencesRepository.getSelectedSubscriptionId() + val nextSubId = getNextSubscriptionId(currentSubId) + + preferencesRepository.setSelectedSubscriptionId(nextSubId) + selectedSubId = nextSubId + + withContext(Dispatchers.Main) { + refreshTileDisplay() + } + } catch (_: CancellationException) { + // Expected when job is cancelled + } catch (_: Exception) { + // Handle errors silently + } + } + } + + /** + * Determine the next subscription ID in the cycle. + * Order: Auto (-1) → SIM 1 → SIM 2 → ... → Auto (-1) + */ + private fun getNextSubscriptionId(currentSubId: Int): Int { + if (availableSims.isEmpty()) return -1 + + if (currentSubId == -1) { + // Currently on Auto, switch to first SIM + return availableSims.first().subscriptionId + } + + // Find current SIM index + val currentIndex = availableSims.indexOfFirst { it.subscriptionId == currentSubId } + + return if (currentIndex == -1 || currentIndex >= availableSims.size - 1) { + // Current SIM not found or is the last one, cycle back to Auto + -1 + } else { + // Move to next SIM + availableSims[currentIndex + 1].subscriptionId + } + } + + private fun refreshTileDisplay() { + try { + val tile = qsTile ?: return + + if (!hasReadPhoneStatePermission()) { + tile.state = Tile.STATE_INACTIVE + tile.label = "SIM Select" + tile.subtitle = "Permission needed" + tile.updateTile() + return + } + + if (availableSims.size <= 1) { + tile.state = Tile.STATE_INACTIVE + tile.label = "SIM Select" + tile.subtitle = if (availableSims.size == 1) { + availableSims.first().displayName + } else { + "No SIM detected" + } + tile.updateTile() + return + } + + // Multiple SIMs available — tile is active + tile.state = Tile.STATE_ACTIVE + + if (selectedSubId == -1) { + tile.label = "SIM: Auto" + tile.subtitle = "System default" + } else { + val simInfo = availableSims.find { it.subscriptionId == selectedSubId } + if (simInfo != null) { + tile.label = "SIM: ${simInfo.displayName}" + tile.subtitle = "Tap to switch" + } else { + // Selected SIM no longer available + tile.label = "SIM: Auto" + tile.subtitle = "Previous SIM removed" + } + } + + tile.updateTile() + } catch (_: Exception) { + // Handle tile update errors silently + } + } + + private fun hasReadPhoneStatePermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } +} diff --git a/app/src/main/res/drawable/ic_sim_card.xml b/app/src/main/res/drawable/ic_sim_card.xml new file mode 100644 index 0000000..5c904a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_sim_card.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801869..a2bc77d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,7 @@ Mode B Save Configuration Configuration saved successfully + + + SIM Select \ No newline at end of file From 0474bd6d8aa0681de2fe511261b4de655f37a3b6 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 17 Aug 2026 20:21:13 +0530 Subject: [PATCH 06/22] fix: distinguish an unreadable SIM list from an empty one - SimRepository now returns SimQueryResult, so a denied permission or a failed query no longer looks like a device with no SIMs. - Only a loaded result can invalidate a stored SIM selection, in the effective-subscription use case, the settings view model and the SIM tile. - SIMs are also ordered by slot. --- .../data/repository/SimRepositoryImpl.kt | 32 +++-- .../domain/model/NetworkSwitchModels.kt | 13 ++ .../domain/repository/Repositories.kt | 9 +- .../domain/usecase/NetworkUseCases.kt | 37 +++--- .../viewmodel/SettingsViewModel.kt | 25 ++-- .../service/SimSwitchTileService.kt | 55 ++++---- .../usecase/GetAvailableSimsUseCaseTest.kt | 117 ++++++++---------- .../GetEffectiveSubscriptionIdUseCaseTest.kt | 48 +++++-- .../viewmodel/SettingsViewModelTest.kt | 7 +- 9 files changed, 182 insertions(+), 161 deletions(-) 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 index 90c0aa1..5b9c46f 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt @@ -6,8 +6,10 @@ import android.content.pm.PackageManager import android.os.Build import android.telephony.SubscriptionInfo import android.telephony.SubscriptionManager +import android.util.Log import androidx.core.content.ContextCompat import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.SimRepository import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -22,36 +24,40 @@ class SimRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context ) : SimRepository { + private companion object { + const val TAG = "NetworkSwitch" + } + 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 + override suspend fun getAvailableSimCards(): SimQueryResult { if (!hasReadPhoneStatePermission()) { - return emptyList() + return SimQueryResult.PermissionDenied } - val manager = subscriptionManager ?: return emptyList() + val manager = subscriptionManager + ?: return SimQueryResult.Failed(IllegalStateException("SubscriptionManager unavailable")) 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) - } + SimQueryResult.Loaded( + subscriptions + .mapNotNull { mapToSimInfo(it) } + .sortedBy { it.simSlotIndex } + ) } catch (e: SecurityException) { - // Permission was revoked or not granted - emptyList() + Log.w(TAG, "SIM query denied", e) + SimQueryResult.PermissionDenied } catch (e: Exception) { - // Handle other potential errors - emptyList() + Log.w(TAG, "SIM query failed", e) + SimQueryResult.Failed(e) } } 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 0e6259e..a53f52f 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 @@ -98,3 +98,16 @@ data class SimInfo( val simSlotIndex: Int, val displayName: String ) + +/** + * Outcome of querying the device for SIM cards. + * + * [Loaded] is the only outcome that reports the true set of SIMs. The others mean the + * set could not be determined, which is not the same as the device having no SIMs — a + * stored SIM selection must survive them. + */ +sealed interface SimQueryResult { + data class Loaded(val sims: List) : SimQueryResult + data object PermissionDenied : SimQueryResult + data class Failed(val cause: Throwable) : SimQueryResult +} 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 622af44..b46a21d 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 @@ -4,6 +4,7 @@ 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.SimQueryResult import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow @@ -89,8 +90,10 @@ interface PreferencesRepository { */ interface SimRepository { /** - * Get list of all available SIM cards in the device - * Returns empty list if permission is not granted + * Query the available SIM cards, ordered by SIM slot. + * + * Returns [SimQueryResult.PermissionDenied] or [SimQueryResult.Failed] when the set + * cannot be determined, so callers can tell that apart from a device with no SIMs. */ - suspend fun getAvailableSimCards(): List + suspend fun getAvailableSimCards(): SimQueryResult } 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 04f3969..0fcd2f8 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 @@ -5,6 +5,7 @@ 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.SimQueryResult import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -102,12 +103,11 @@ class UpdateToggleModeConfigUseCase @Inject constructor( class GetAvailableSimsUseCase @Inject constructor( private val simRepository: SimRepository ) { - suspend operator fun invoke(): Result> { + suspend operator fun invoke(): SimQueryResult { return try { - val sims = simRepository.getAvailableSimCards() - Result.success(sims) + simRepository.getAvailableSimCards() } catch (e: Exception) { - Result.failure(e) + SimQueryResult.Failed(e) } } } @@ -145,32 +145,33 @@ class GetEffectiveSubscriptionIdUseCase @Inject constructor( ) { 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 { + + val query = try { simRepository.getAvailableSimCards() } catch (e: Exception) { - // If we can't check, use the selected ID anyway - return selectedSubId + SimQueryResult.Failed(e) } - - // Check if the selected SIM is still available - val isSimAvailable = availableSims.any { it.subscriptionId == selectedSubId } - - return if (isSimAvailable) { - // Selected SIM still exists, use it + + // Only a successful query proves the SIM is gone. Anything else leaves the + // selection intact, so a denied permission or a transient failure cannot + // silently discard it. + val sims = when (query) { + is SimQueryResult.Loaded -> query.sims + SimQueryResult.PermissionDenied, is SimQueryResult.Failed -> return selectedSubId + } + + return if (sims.any { it.subscriptionId == selectedSubId }) { selectedSubId } else { - // Selected SIM was removed, fall back to default and reset preference try { preferencesRepository.setSelectedSubscriptionId(-1) } catch (e: Exception) { - // Silent catch + // Selection could not be reset; the default is still the safe target. } SubscriptionManager.getDefaultDataSubscriptionId() } 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 0a3172b..2ff71d3 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 @@ -8,6 +8,7 @@ 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.model.SimQueryResult import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.usecase.GetAvailableSimsUseCase @@ -107,16 +108,20 @@ class SettingsViewModel @Inject constructor( _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" + when (val result = getAvailableSimsUseCase()) { + is SimQueryResult.Loaded -> { + _availableSims.value = result.sims + // Only a successful query can prove a selected SIM is gone. + validateSelectedSim(result.sims) + } + SimQueryResult.PermissionDenied -> { + _availableSims.value = emptyList() + _simError.value = "Permission required to detect SIM cards" + } + is SimQueryResult.Failed -> { + _availableSims.value = emptyList() + _simError.value = "Failed to detect SIM cards" + } } } catch (e: Exception) { _availableSims.value = emptyList() diff --git a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt index b9a63bc..4bcb449 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt @@ -1,16 +1,13 @@ package com.supernova.networkswitch.service -import android.Manifest -import android.content.pm.PackageManager import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import androidx.core.content.ContextCompat import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.repository.SimRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* -import kotlinx.coroutines.flow.combine import javax.inject.Inject @AndroidEntryPoint @@ -25,20 +22,18 @@ class SimSwitchTileService : TileService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var listeningJob: Job? = null - private var availableSims: List = emptyList() + private var simQuery: SimQueryResult = SimQueryResult.Loaded(emptyList()) private var selectedSubId: Int = -1 + private val availableSims: List + get() = (simQuery as? SimQueryResult.Loaded)?.sims ?: emptyList() + override fun onStartListening() { super.onStartListening() listeningJob?.cancel() listeningJob = serviceScope.launch { try { - // Load available SIMs first - availableSims = try { - simRepository.getAvailableSimCards() - } catch (_: Exception) { - emptyList() - } + simQuery = querySims() // Then observe the selected subscription ID and update tile preferencesRepository.observeSelectedSubscriptionId().collect { subId -> @@ -66,23 +61,10 @@ class SimSwitchTileService : TileService() { serviceScope.launch { try { - // Check permission - if (!hasReadPhoneStatePermission()) { - withContext(Dispatchers.Main) { - refreshTileDisplay() - } - return@launch - } + simQuery = querySims() - // Refresh available SIMs in case they changed - availableSims = try { - simRepository.getAvailableSimCards() - } catch (_: Exception) { - emptyList() - } - - // Single SIM or no SIMs — nothing to cycle - if (availableSims.size <= 1) { + // Cannot cycle without a known SIM set, or with a single SIM + if (simQuery !is SimQueryResult.Loaded || availableSims.size <= 1) { withContext(Dispatchers.Main) { refreshTileDisplay() } @@ -135,7 +117,7 @@ class SimSwitchTileService : TileService() { try { val tile = qsTile ?: return - if (!hasReadPhoneStatePermission()) { + if (simQuery is SimQueryResult.PermissionDenied) { tile.state = Tile.STATE_INACTIVE tile.label = "SIM Select" tile.subtitle = "Permission needed" @@ -143,6 +125,14 @@ class SimSwitchTileService : TileService() { return } + if (simQuery is SimQueryResult.Failed) { + tile.state = Tile.STATE_INACTIVE + tile.label = "SIM Select" + tile.subtitle = "Unavailable" + tile.updateTile() + return + } + if (availableSims.size <= 1) { tile.state = Tile.STATE_INACTIVE tile.label = "SIM Select" @@ -179,11 +169,10 @@ class SimSwitchTileService : TileService() { } } - private fun hasReadPhoneStatePermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, - Manifest.permission.READ_PHONE_STATE - ) == PackageManager.PERMISSION_GRANTED + private suspend fun querySims(): SimQueryResult = try { + simRepository.getAvailableSimCards() + } catch (e: Exception) { + SimQueryResult.Failed(e) } override fun onDestroy() { 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 index 9ec9d18..c7472a0 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt @@ -1,6 +1,7 @@ package com.supernova.networkswitch.domain.usecase import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.SimRepository import io.mockk.coEvery import io.mockk.mockk @@ -13,106 +14,86 @@ 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 + fun `should pass through the loaded SIM list`() = runTest { 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) + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(simList) + + assertEquals(SimQueryResult.Loaded(simList), useCase()) } - + @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()) + fun `should report an empty device as loaded, not as a failure`() = runTest { + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(emptyList()) + + assertEquals(SimQueryResult.Loaded(emptyList()), useCase()) } - + + /** + * A denied permission must stay distinguishable from a device with no SIMs, since + * only the latter justifies discarding a stored SIM selection. + */ @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 + fun `should report permission denied distinctly from an empty list`() = runTest { + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.PermissionDenied + val result = useCase() - - // Then - assertTrue(result.isSuccess) - assertTrue(result.getOrNull()?.isEmpty() == true) + + assertEquals(SimQueryResult.PermissionDenied, result) + assertNotEquals(SimQueryResult.Loaded(emptyList()), result) } - + @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()) + fun `should report a repository failure as Failed`() = runTest { + val cause = IllegalStateException("SubscriptionManager unavailable") + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Failed(cause) + + assertEquals(cause, (useCase() as SimQueryResult.Failed).cause) + } + + @Test + fun `should wrap an unexpected throw as Failed`() = runTest { + val thrown = RuntimeException("boom") + coEvery { simRepository.getAvailableSimCards() } throws thrown + + assertEquals(thrown, (useCase() as SimQueryResult.Failed).cause) } - + @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) + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(singleSim) + + val result = useCase() as SimQueryResult.Loaded + + assertEquals(1, result.sims.size) + assertEquals(1, result.sims.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) + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(tripleSim) + + assertEquals(3, (useCase() as SimQueryResult.Loaded).sims.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 index 0cefb8a..633d517 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt @@ -2,6 +2,7 @@ package com.supernova.networkswitch.domain.usecase import android.telephony.SubscriptionManager import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.repository.SimRepository import io.mockk.coEvery @@ -63,7 +64,7 @@ class GetEffectiveSubscriptionIdUseCaseTest { SimInfo(subscriptionId = 42, simSlotIndex = 0, displayName = "SIM 1") ) coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId - coEvery { simRepository.getAvailableSimCards() } returns availableSims + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(availableSims) // When val result = useCase() @@ -80,7 +81,7 @@ class GetEffectiveSubscriptionIdUseCaseTest { SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1") ) coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId - coEvery { simRepository.getAvailableSimCards() } returns availableSims + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(availableSims) coEvery { preferencesRepository.setSelectedSubscriptionId(-1) } returns Unit // When @@ -91,20 +92,41 @@ class GetEffectiveSubscriptionIdUseCaseTest { coVerify { preferencesRepository.setSelectedSubscriptionId(-1) } } + /** + * A denied permission is the reachable form of "cannot determine": the repository + * reports it rather than throwing. The selection must survive it untouched. + */ @Test - fun `should use selected ID when cannot check SIM availability`() = runTest { - // Given + fun `should keep the selection and not reset it when permission is denied`() = runTest { + val selectedSubId = 42 + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.PermissionDenied + + assertEquals(selectedSubId, useCase()) + coVerify(exactly = 0) { preferencesRepository.setSelectedSubscriptionId(any()) } + } + + @Test + fun `should keep the selection and not reset it when the query fails`() = runTest { + val selectedSubId = 42 + coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId + coEvery { simRepository.getAvailableSimCards() } returns + SimQueryResult.Failed(IllegalStateException("transient")) + + assertEquals(selectedSubId, useCase()) + coVerify(exactly = 0) { preferencesRepository.setSelectedSubscriptionId(any()) } + } + + @Test + fun `should use selected ID when the repository throws unexpectedly`() = runTest { val selectedSubId = 42 coEvery { preferencesRepository.getSelectedSubscriptionId() } returns selectedSubId coEvery { simRepository.getAvailableSimCards() } throws SecurityException("Permission denied") - - // When - val result = useCase() - - // Then - assertEquals(selectedSubId, result) + + assertEquals(selectedSubId, useCase()) + coVerify(exactly = 0) { preferencesRepository.setSelectedSubscriptionId(any()) } } - + @Test fun `should return subscription ID 1 when user selected SIM 1 and it exists`() = runTest { // Given @@ -113,7 +135,7 @@ class GetEffectiveSubscriptionIdUseCaseTest { SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2") ) coEvery { preferencesRepository.getSelectedSubscriptionId() } returns 1 - coEvery { simRepository.getAvailableSimCards() } returns availableSims + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(availableSims) // When val result = useCase() @@ -130,7 +152,7 @@ class GetEffectiveSubscriptionIdUseCaseTest { SimInfo(subscriptionId = 2, simSlotIndex = 1, displayName = "SIM 2") ) coEvery { preferencesRepository.getSelectedSubscriptionId() } returns 2 - coEvery { simRepository.getAvailableSimCards() } returns availableSims + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.Loaded(availableSims) // When val result = useCase() 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 b5b8463..dc6abf6 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 @@ -3,6 +3,7 @@ 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.model.SimQueryResult import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.usecase.GetAvailableSimsUseCase @@ -44,7 +45,7 @@ class SettingsViewModelTest { coEvery { preferencesRepository.observeControlMethod() } returns flowOf(ControlMethod.SHIZUKU) coEvery { preferencesRepository.observeSelectedSubscriptionId() } returns flowOf(-1) - coEvery { getAvailableSimsUseCase() } returns Result.success(emptyList()) + coEvery { getAvailableSimsUseCase() } returns SimQueryResult.Loaded(emptyList()) coEvery { getSelectedSubscriptionIdUseCase() } returns -1 } @@ -106,7 +107,7 @@ class SettingsViewModelTest { @Test fun `selectSim sets error for invalid SIM`() = runTest { // Mock available SIMs to contain only simId = 1 - coEvery { getAvailableSimsUseCase() } returns Result.success( + coEvery { getAvailableSimsUseCase() } returns SimQueryResult.Loaded( listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1")) ) createViewModel() @@ -119,7 +120,7 @@ class SettingsViewModelTest { fun `selectSim clears error for valid SIM`() = runTest { val validSimId = 1 // Mock available SIMs to contain simId = 1 - coEvery { getAvailableSimsUseCase() } returns Result.success( + coEvery { getAvailableSimsUseCase() } returns SimQueryResult.Loaded( listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1")) ) createViewModel() From 8763e77080522388c8ead7618bbede7ed2641f45 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 17 Aug 2026 20:56:12 +0530 Subject: [PATCH 07/22] refactor: resolve the effective subscription id in one place checkCompatibility now takes a subId like the other repository operations, so the unvalidated copy of the resolution logic can go and compatibility is probed against the SIM the app will actually operate on. --- .../NetworkControlRepositoryImpl.kt | 25 ++----------------- .../domain/repository/Repositories.kt | 2 +- .../domain/usecase/NetworkUseCases.kt | 5 ++-- .../viewmodel/SettingsViewModel.kt | 9 ++++--- .../viewmodel/SettingsViewModelTest.kt | 21 +++++++++++----- 5 files changed, 27 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index c8f3385..0abcf24 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.supernova.networkswitch.data.repository -import android.telephony.SubscriptionManager import com.supernova.networkswitch.data.source.NetworkControlDataSource import com.supernova.networkswitch.data.source.RootNetworkControlDataSource import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource @@ -22,18 +21,14 @@ class NetworkControlRepositoryImpl @Inject constructor( private val preferencesRepository: PreferencesRepository ) : NetworkControlRepository { - override suspend fun checkCompatibility(method: ControlMethod): CompatibilityState { - val dataSource = getDataSource(method) - // Use selected subscription ID for compatibility check - val subId = getEffectiveSubscriptionId() - return dataSource.checkCompatibility(subId) + override suspend fun checkCompatibility(method: ControlMethod, subId: Int): CompatibilityState { + return getDataSource(method).checkCompatibility(subId) } override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val method = preferencesRepository.getControlMethod() val dataSource = getDataSource(method) - // Use the provided subId (which should come from getEffectiveSubscriptionId in callers) dataSource.getCurrentNetworkMode(subId) } catch (e: Exception) { null @@ -44,7 +39,6 @@ class NetworkControlRepositoryImpl @Inject constructor( return try { val method = preferencesRepository.getControlMethod() val dataSource = getDataSource(method) - // Use the provided subId (which should come from getEffectiveSubscriptionId in callers) dataSource.setNetworkMode(subId, mode) Result.success(Unit) } catch (e: Exception) { @@ -60,21 +54,6 @@ class NetworkControlRepositoryImpl @Inject constructor( shizukuDataSource.resetConnection() } - /** - * Get the effective subscription ID to use for network operations - * Returns the user's selected subscription ID from preferences, or the default if -1 - */ - private suspend fun getEffectiveSubscriptionId(): Int { - val selectedSubId = preferencesRepository.getSelectedSubscriptionId() - return if (selectedSubId == -1) { - // User selected "Auto" - use system default - SubscriptionManager.getDefaultDataSubscriptionId() - } else { - // User selected a specific SIM - selectedSubId - } - } - private fun getDataSource(method: ControlMethod): NetworkControlDataSource { return when (method) { ControlMethod.ROOT -> rootDataSource 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 b46a21d..86749a3 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 @@ -15,7 +15,7 @@ interface NetworkControlRepository { /** * Check if network control is compatible with current device/method */ - suspend fun checkCompatibility(method: ControlMethod): CompatibilityState + suspend fun checkCompatibility(method: ControlMethod, subId: Int): CompatibilityState /** * Get current network mode 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 0fcd2f8..66ccaa2 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 @@ -14,11 +14,12 @@ import javax.inject.Inject class CheckCompatibilityUseCase @Inject constructor( private val networkControlRepository: NetworkControlRepository, - private val preferencesRepository: PreferencesRepository + private val preferencesRepository: PreferencesRepository, + private val getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase ) { suspend operator fun invoke(): CompatibilityState { val controlMethod = preferencesRepository.getControlMethod() - return networkControlRepository.checkCompatibility(controlMethod) + return networkControlRepository.checkCompatibility(controlMethod, getEffectiveSubscriptionId()) } } 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 2ff71d3..d6f16f8 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 @@ -12,6 +12,7 @@ import com.supernova.networkswitch.domain.model.SimQueryResult 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.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.GetSelectedSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.SetSelectedSubscriptionIdUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,6 +33,7 @@ class SettingsViewModel @Inject constructor( private val preferencesRepository: PreferencesRepository, private val networkControlRepository: NetworkControlRepository, private val getAvailableSimsUseCase: GetAvailableSimsUseCase, + private val getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase, private val getSelectedSubscriptionIdUseCase: GetSelectedSubscriptionIdUseCase, private val setSelectedSubscriptionIdUseCase: SetSelectedSubscriptionIdUseCase ) : ViewModel() { @@ -91,9 +93,10 @@ class SettingsViewModel @Inject constructor( rootCompatibility = CompatibilityState.Pending shizukuCompatibility = CompatibilityState.Pending - // Check both methods in parallel - val rootResult = async { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } - val shizukuResult = async { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + // Both methods are probed against the SIM the app will actually operate on + val subId = getEffectiveSubscriptionId() + val rootResult = async { networkControlRepository.checkCompatibility(ControlMethod.ROOT, subId) } + val shizukuResult = async { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU, subId) } rootCompatibility = rootResult.await() shizukuCompatibility = shizukuResult.await() 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 dc6abf6..e5d9f14 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 @@ -7,6 +7,7 @@ import com.supernova.networkswitch.domain.model.SimQueryResult 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.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.GetSelectedSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.SetSelectedSubscriptionIdUseCase import com.supernova.networkswitch.util.CoroutineTestRule @@ -30,6 +31,7 @@ class SettingsViewModelTest { private lateinit var preferencesRepository: PreferencesRepository private lateinit var networkControlRepository: NetworkControlRepository private lateinit var getAvailableSimsUseCase: GetAvailableSimsUseCase + private lateinit var getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase private lateinit var getSelectedSubscriptionIdUseCase: GetSelectedSubscriptionIdUseCase private lateinit var setSelectedSubscriptionIdUseCase: SetSelectedSubscriptionIdUseCase @@ -40,6 +42,7 @@ class SettingsViewModelTest { preferencesRepository = mockk(relaxed = true) networkControlRepository = mockk(relaxed = true) getAvailableSimsUseCase = mockk(relaxed = true) + getEffectiveSubscriptionIdUseCase = mockk(relaxed = true) getSelectedSubscriptionIdUseCase = mockk(relaxed = true) setSelectedSubscriptionIdUseCase = mockk(relaxed = true) @@ -47,6 +50,7 @@ class SettingsViewModelTest { coEvery { preferencesRepository.observeSelectedSubscriptionId() } returns flowOf(-1) coEvery { getAvailableSimsUseCase() } returns SimQueryResult.Loaded(emptyList()) coEvery { getSelectedSubscriptionIdUseCase() } returns -1 + coEvery { getEffectiveSubscriptionIdUseCase() } returns DEFAULT_SUB_ID } private fun createViewModel() { @@ -54,6 +58,7 @@ class SettingsViewModelTest { preferencesRepository, networkControlRepository, getAvailableSimsUseCase, + getEffectiveSubscriptionIdUseCase, getSelectedSubscriptionIdUseCase, setSelectedSubscriptionIdUseCase ) @@ -62,16 +67,16 @@ class SettingsViewModelTest { @Test fun `init calls checkAllCompatibility`() = runTest { createViewModel() - coVerify { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } - coVerify { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + coVerify { networkControlRepository.checkCompatibility(ControlMethod.ROOT, DEFAULT_SUB_ID) } + coVerify { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU, DEFAULT_SUB_ID) } } @Test fun `checkAllCompatibility updates compatibility states`() = runTest { val rootState = CompatibilityState.Compatible val shizukuState = CompatibilityState.Incompatible("Test Reason") - coEvery { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } returns rootState - coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } returns shizukuState + coEvery { networkControlRepository.checkCompatibility(ControlMethod.ROOT, DEFAULT_SUB_ID) } returns rootState + coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU, DEFAULT_SUB_ID) } returns shizukuState createViewModel() @@ -91,8 +96,8 @@ class SettingsViewModelTest { fun `retryCompatibilityCheck calls checkAllCompatibility`() = runTest { createViewModel() viewModel.retryCompatibilityCheck() - coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } - coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.ROOT, DEFAULT_SUB_ID) } + coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU, DEFAULT_SUB_ID) } } @Test @@ -127,4 +132,8 @@ class SettingsViewModelTest { viewModel.selectSim(validSimId) assertEquals(null, viewModel.simError.value) } + + private companion object { + const val DEFAULT_SUB_ID = 1 + } } From 6c6a4b2092945b49004efbe5b8ff339718efb9f2 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 17 Aug 2026 21:33:52 +0530 Subject: [PATCH 08/22] feat: track SIM changes live in the SIM tile SimRepository exposes the SIM set as a flow backed by OnSubscriptionsChangedListener, so the tile reflects a hot-swapped SIM while the panel is open instead of showing the set captured when listening began. --- .../data/repository/SimRepositoryImpl.kt | 40 +++++++++++++- .../domain/repository/Repositories.kt | 7 +++ .../service/SimSwitchTileService.kt | 52 +++++++++++-------- 3 files changed, 75 insertions(+), 24 deletions(-) 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 index 5b9c46f..e49023b 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt @@ -4,6 +4,8 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.os.Handler +import android.os.Looper import android.telephony.SubscriptionInfo import android.telephony.SubscriptionManager import android.util.Log @@ -12,6 +14,10 @@ import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.SimRepository import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import javax.inject.Inject import javax.inject.Singleton @@ -32,7 +38,39 @@ class SimRepositoryImpl @Inject constructor( context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager } - override suspend fun getAvailableSimCards(): SimQueryResult { + override suspend fun getAvailableSimCards(): SimQueryResult = querySims() + + /** + * The platform invokes a newly registered listener once immediately, which supplies + * the initial emission. Registration needs a Looper on the calling thread, so it is + * posted to the main thread rather than run on the collector's dispatcher. + */ + override fun observeAvailableSimCards(): Flow = callbackFlow { + val producer = this + val manager = subscriptionManager + if (manager == null || !hasReadPhoneStatePermission()) { + trySend(querySims()) + awaitClose { } + return@callbackFlow + } + + val listener = object : SubscriptionManager.OnSubscriptionsChangedListener() { + override fun onSubscriptionsChanged() { + // Reading the subscription list is a binder call, so keep it off the + // main thread the listener is invoked on. + producer.launch { trySend(querySims()) } + } + } + + val handler = Handler(Looper.getMainLooper()) + handler.post { manager.addOnSubscriptionsChangedListener(listener) } + + // Removal is posted to the same Looper so it cannot overtake a registration + // that is still queued, which would leave the listener attached for good. + awaitClose { handler.post { manager.removeOnSubscriptionsChangedListener(listener) } } + } + + private fun querySims(): SimQueryResult { if (!hasReadPhoneStatePermission()) { return SimQueryResult.PermissionDenied } 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 86749a3..5182efc 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 @@ -96,4 +96,11 @@ interface SimRepository { * cannot be determined, so callers can tell that apart from a device with no SIMs. */ suspend fun getAvailableSimCards(): SimQueryResult + + /** + * Emits the current SIM set, then again whenever the device's subscriptions change. + * + * Emits once on collection so a collector needs no separate initial query. + */ + fun observeAvailableSimCards(): Flow } diff --git a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt index 4bcb449..a5aa6d3 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt @@ -2,12 +2,14 @@ package com.supernova.networkswitch.service import android.service.quicksettings.Tile import android.service.quicksettings.TileService +import android.util.Log import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.repository.SimRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* +import kotlinx.coroutines.flow.combine import javax.inject.Inject @AndroidEntryPoint @@ -33,19 +35,21 @@ class SimSwitchTileService : TileService() { listeningJob?.cancel() listeningJob = serviceScope.launch { try { - simQuery = querySims() - - // Then observe the selected subscription ID and update tile - preferencesRepository.observeSelectedSubscriptionId().collect { subId -> - selectedSubId = subId - withContext(Dispatchers.Main) { - refreshTileDisplay() + combine( + simRepository.observeAvailableSimCards(), + preferencesRepository.observeSelectedSubscriptionId() + ) { query, subId -> query to subId } + .collect { (query, subId) -> + simQuery = query + selectedSubId = subId + withContext(Dispatchers.Main) { + refreshTileDisplay() + } } - } } catch (_: CancellationException) { - // Expected when job is cancelled - } catch (_: Exception) { - // Handle errors silently + // Expected when the tile stops listening + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to observe SIM state", e) } } } @@ -61,7 +65,11 @@ class SimSwitchTileService : TileService() { serviceScope.launch { try { - simQuery = querySims() + // A click can land before the observer's first emission, so make sure + // the SIM set is known rather than treating "not yet loaded" as "none". + if (availableSims.isEmpty()) { + simQuery = simRepository.getAvailableSimCards() + } // Cannot cycle without a known SIM set, or with a single SIM if (simQuery !is SimQueryResult.Loaded || availableSims.size <= 1) { @@ -82,9 +90,9 @@ class SimSwitchTileService : TileService() { refreshTileDisplay() } } catch (_: CancellationException) { - // Expected when job is cancelled - } catch (_: Exception) { - // Handle errors silently + // Expected when the tile stops listening + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to cycle SIM", e) } } } @@ -164,19 +172,17 @@ class SimSwitchTileService : TileService() { } tile.updateTile() - } catch (_: Exception) { - // Handle tile update errors silently + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to update tile", e) } } - private suspend fun querySims(): SimQueryResult = try { - simRepository.getAvailableSimCards() - } catch (e: Exception) { - SimQueryResult.Failed(e) - } - override fun onDestroy() { super.onDestroy() serviceScope.cancel() } + + private companion object { + const val TAG = "NetworkSwitch" + } } From d6db1ddffb52f110ae9a6dc7131b40a967cd4ef0 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 17 Aug 2026 21:41:29 +0530 Subject: [PATCH 09/22] refactor: name the Auto SIM selection instead of repeating -1 --- .../data/source/PreferencesDataSource.kt | 8 ++++---- .../domain/model/NetworkSwitchModels.kt | 14 ++++++++++++++ .../domain/repository/Repositories.kt | 5 +++-- .../domain/usecase/NetworkUseCases.kt | 6 +++--- .../presentation/ui/activity/SettingsActivity.kt | 7 ++++--- .../presentation/viewmodel/SettingsViewModel.kt | 16 ++++++++-------- .../service/SimSwitchTileService.kt | 13 +++++++------ 7 files changed, 43 insertions(+), 26 deletions(-) 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 161c0f0..0323f04 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 @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow @@ -33,8 +34,7 @@ class PreferencesDataSource @Inject constructor( 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 const val DEFAULT_SUBSCRIPTION_ID = SubscriptionSelection.AUTO } private fun parseControlMethod(methodString: String?): ControlMethod { @@ -99,7 +99,7 @@ class PreferencesDataSource @Inject constructor( /** * Get the selected subscription ID for the SIM card - * Returns -1 if no specific SIM is selected (use default) + * Returns [SubscriptionSelection.AUTO] if no specific SIM is selected */ suspend fun getSelectedSubscriptionId(): Int { return dataStore.data.map { preferences -> @@ -109,7 +109,7 @@ class PreferencesDataSource @Inject constructor( /** * Set the selected subscription ID for the SIM card - * Pass -1 to use the default subscription + * Pass [SubscriptionSelection.AUTO] to use the default subscription */ suspend fun setSelectedSubscriptionId(subscriptionId: Int) { dataStore.edit { preferences -> 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 a53f52f..e6f4e68 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 @@ -99,6 +99,20 @@ data class SimInfo( val displayName: String ) +/** + * Values for the stored SIM selection. + */ +object SubscriptionSelection { + /** + * Follow the system default data subscription instead of a fixed SIM. + * + * Equal to `SubscriptionManager.INVALID_SUBSCRIPTION_ID`, so a subscription ID read + * back from the platform can never be distinguished from this. Only compare against + * it for a value that came from the stored selection. + */ + const val AUTO = -1 +} + /** * Outcome of querying the device for SIM cards. * 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 5182efc..ef6cffb 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 @@ -5,6 +5,7 @@ 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.SimQueryResult +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow @@ -69,13 +70,13 @@ interface PreferencesRepository { /** * Get the selected subscription ID for the SIM card - * Returns -1 if no specific SIM is selected (use default) + * Returns [SubscriptionSelection.AUTO] if no specific SIM is selected */ suspend fun getSelectedSubscriptionId(): Int /** * Set the selected subscription ID for the SIM card - * Pass -1 to use the default subscription + * Pass [SubscriptionSelection.AUTO] to use the default subscription */ suspend fun setSelectedSubscriptionId(subscriptionId: Int) 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 66ccaa2..e51e94b 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 @@ -6,6 +6,7 @@ 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.SimQueryResult +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -147,8 +148,7 @@ class GetEffectiveSubscriptionIdUseCase @Inject constructor( suspend operator fun invoke(): Int { val selectedSubId = preferencesRepository.getSelectedSubscriptionId() - // If Auto mode (-1), use system default - if (selectedSubId == -1) { + if (selectedSubId == SubscriptionSelection.AUTO) { return SubscriptionManager.getDefaultDataSubscriptionId() } @@ -170,7 +170,7 @@ class GetEffectiveSubscriptionIdUseCase @Inject constructor( selectedSubId } else { try { - preferencesRepository.setSelectedSubscriptionId(-1) + preferencesRepository.setSelectedSubscriptionId(SubscriptionSelection.AUTO) } catch (e: Exception) { // Selection could not be reset; the default is still the safe target. } 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 8c8cba7..2abfcc2 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 @@ -35,6 +35,7 @@ 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.domain.model.SubscriptionSelection import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme import com.supernova.networkswitch.presentation.viewmodel.SettingsViewModel import dagger.hilt.android.AndroidEntryPoint @@ -647,11 +648,11 @@ private fun SimSelectionCard( } }, onClick = { - onSimSelected(-1) + onSimSelected(SubscriptionSelection.AUTO) expanded = false }, leadingIcon = { - if (selectedSubscriptionId == -1) { + if (selectedSubscriptionId == SubscriptionSelection.AUTO) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Selected", @@ -706,7 +707,7 @@ private fun getSelectedSimDisplayName( availableSims: List, selectedSubscriptionId: Int ): String { - if (selectedSubscriptionId == -1) { + if (selectedSubscriptionId == SubscriptionSelection.AUTO) { return "Auto (System Default)" } return availableSims.find { it.subscriptionId == selectedSubscriptionId }?.displayName 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 d6f16f8..14aa857 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 @@ -9,6 +9,7 @@ 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.model.SimQueryResult +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.usecase.GetAvailableSimsUseCase @@ -61,7 +62,7 @@ class SettingsViewModel @Inject constructor( .stateIn( scope = viewModelScope, started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), - initialValue = -1 // Default: use system default + initialValue = SubscriptionSelection.AUTO ) // Loading state for SIM detection @@ -142,8 +143,7 @@ class SettingsViewModel @Inject constructor( private suspend fun validateSelectedSim(availableSims: List) { val currentSelection = selectedSubscriptionId.value - // Skip validation if Auto mode (-1) or if no selection - if (currentSelection == -1) return + if (currentSelection == SubscriptionSelection.AUTO) return // Check if the selected SIM is still in the available list val isStillAvailable = availableSims.any { it.subscriptionId == currentSelection } @@ -151,7 +151,7 @@ class SettingsViewModel @Inject constructor( if (!isStillAvailable) { // Selected SIM was removed, reset to Auto mode try { - setSelectedSubscriptionIdUseCase(-1) + setSelectedSubscriptionIdUseCase(SubscriptionSelection.AUTO) _simError.value = "Previously selected SIM was removed. Switched to Auto mode." } catch (e: Exception) { _simError.value = "Failed to update SIM selection" @@ -168,7 +168,7 @@ class SettingsViewModel @Inject constructor( private fun observeSelectedSimValidity() { viewModelScope.launch { selectedSubscriptionId.collectLatest { subId -> - if (subId != -1 && _availableSims.value.isNotEmpty()) { + if (subId != SubscriptionSelection.AUTO && _availableSims.value.isNotEmpty()) { val isValid = _availableSims.value.any { it.subscriptionId == subId } if (!isValid) { simValidationMutex.lock() @@ -193,13 +193,13 @@ class SettingsViewModel @Inject constructor( /** * Select a specific SIM card for network operations - * @param subscriptionId The subscription ID of the SIM to select, or -1 for default + * @param subscriptionId The subscription ID of the SIM to select, or [SubscriptionSelection.AUTO] */ fun selectSim(subscriptionId: Int) { viewModelScope.launch { try { // Validate the selection before saving - if (subscriptionId != -1) { + if (subscriptionId != SubscriptionSelection.AUTO) { val isValid = _availableSims.value.any { it.subscriptionId == subscriptionId } if (!isValid) { _simError.value = "Selected SIM is not available" @@ -228,7 +228,7 @@ class SettingsViewModel @Inject constructor( */ fun getSelectedSimInfo(): SimInfo? { val currentSubscriptionId = selectedSubscriptionId.value - if (currentSubscriptionId == -1) { + if (currentSubscriptionId == SubscriptionSelection.AUTO) { return null // No specific SIM selected } return _availableSims.value.find { it.subscriptionId == currentSubscriptionId } diff --git a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt index a5aa6d3..59394ff 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt @@ -5,6 +5,7 @@ import android.service.quicksettings.TileService import android.util.Log import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.SimQueryResult +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.repository.SimRepository import dagger.hilt.android.AndroidEntryPoint @@ -25,7 +26,7 @@ class SimSwitchTileService : TileService() { private var listeningJob: Job? = null private var simQuery: SimQueryResult = SimQueryResult.Loaded(emptyList()) - private var selectedSubId: Int = -1 + private var selectedSubId: Int = SubscriptionSelection.AUTO private val availableSims: List get() = (simQuery as? SimQueryResult.Loaded)?.sims ?: emptyList() @@ -99,12 +100,12 @@ class SimSwitchTileService : TileService() { /** * Determine the next subscription ID in the cycle. - * Order: Auto (-1) → SIM 1 → SIM 2 → ... → Auto (-1) + * Order: Auto → SIM 1 → SIM 2 → ... → Auto */ private fun getNextSubscriptionId(currentSubId: Int): Int { - if (availableSims.isEmpty()) return -1 + if (availableSims.isEmpty()) return SubscriptionSelection.AUTO - if (currentSubId == -1) { + if (currentSubId == SubscriptionSelection.AUTO) { // Currently on Auto, switch to first SIM return availableSims.first().subscriptionId } @@ -114,7 +115,7 @@ class SimSwitchTileService : TileService() { return if (currentIndex == -1 || currentIndex >= availableSims.size - 1) { // Current SIM not found or is the last one, cycle back to Auto - -1 + SubscriptionSelection.AUTO } else { // Move to next SIM availableSims[currentIndex + 1].subscriptionId @@ -156,7 +157,7 @@ class SimSwitchTileService : TileService() { // Multiple SIMs available — tile is active tile.state = Tile.STATE_ACTIVE - if (selectedSubId == -1) { + if (selectedSubId == SubscriptionSelection.AUTO) { tile.label = "SIM: Auto" tile.subtitle = "System default" } else { From 2d7946333ca09fe8e38f3acd3704db79e37ff7f7 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Tue, 18 Aug 2026 08:12:31 +0530 Subject: [PATCH 10/22] refactor: drop unreachable API guards and log SIM query failures remove dead branches --- .../data/repository/SimRepositoryImpl.kt | 77 ++++++++----------- 1 file changed, 33 insertions(+), 44 deletions(-) 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 index e49023b..f17d988 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt @@ -42,8 +42,7 @@ class SimRepositoryImpl @Inject constructor( /** * The platform invokes a newly registered listener once immediately, which supplies - * the initial emission. Registration needs a Looper on the calling thread, so it is - * posted to the main thread rather than run on the collector's dispatcher. + * the initial emission. */ override fun observeAvailableSimCards(): Flow = callbackFlow { val producer = this @@ -63,10 +62,16 @@ class SimRepositoryImpl @Inject constructor( } val handler = Handler(Looper.getMainLooper()) - handler.post { manager.addOnSubscriptionsChangedListener(listener) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + manager.addOnSubscriptionsChangedListener(context.mainExecutor, listener) + } else { + // The executor-less overload registers against the calling thread's Looper. + @Suppress("DEPRECATION") + handler.post { manager.addOnSubscriptionsChangedListener(listener) } + } - // Removal is posted to the same Looper so it cannot overtake a registration - // that is still queued, which would leave the listener attached for good. + // Posted to the same Looper as the pre-R registration so removal cannot overtake + // a registration still queued on it, which would leave the listener attached. awaitClose { handler.post { manager.removeOnSubscriptionsChangedListener(listener) } } } @@ -79,11 +84,7 @@ class SimRepositoryImpl @Inject constructor( ?: return SimQueryResult.Failed(IllegalStateException("SubscriptionManager unavailable")) return try { - val subscriptions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - manager.activeSubscriptionInfoList ?: emptyList() - } else { - emptyList() - } + val subscriptions = manager.activeSubscriptionInfoList ?: emptyList() SimQueryResult.Loaded( subscriptions @@ -99,9 +100,6 @@ class SimRepositoryImpl @Inject constructor( } } - /** - * Check if READ_PHONE_STATE permission is granted - */ private fun hasReadPhoneStatePermission(): Boolean { return ContextCompat.checkSelfPermission( context, @@ -110,48 +108,39 @@ class SimRepositoryImpl @Inject constructor( } /** - * Map SubscriptionInfo to SimInfo domain model + * Map SubscriptionInfo to SimInfo domain model. + * + * Returns null for a subscription the platform cannot describe, so one unusable + * entry does not discard the rest of the list. */ 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 - } + SimInfo( + subscriptionId = subscriptionInfo.subscriptionId, + simSlotIndex = subscriptionInfo.simSlotIndex, + displayName = buildDisplayName(subscriptionInfo) + ) } catch (e: Exception) { + Log.w(TAG, "Skipping unreadable subscription", e) null } } /** - * Build a user-friendly display name for the SIM card + * Build a user-friendly display name for the SIM card. + * + * A slot index is negative for a subscription that is not tied to a physical slot, + * such as an inactive eSIM profile. */ 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}" - } + val carrierName = subscriptionInfo.displayName?.toString() + val slotIndex = subscriptionInfo.simSlotIndex + + return when { + !carrierName.isNullOrBlank() && slotIndex >= 0 -> "$carrierName (Slot ${slotIndex + 1})" + !carrierName.isNullOrBlank() -> carrierName + slotIndex >= 0 -> "SIM ${slotIndex + 1}" + else -> "SIM ${subscriptionInfo.subscriptionId}" } - return "Unknown SIM" } } From e11956214a31503fb4007f0228d0e9c3179119ab Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Thu, 20 Aug 2026 20:16:46 +0530 Subject: [PATCH 11/22] refactor: move SIM tile labels into string resources --- .../service/SimSwitchTileService.kt | 25 ++++++++++--------- app/src/main/res/values/strings.xml | 8 ++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt index 59394ff..755af78 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt @@ -3,6 +3,7 @@ package com.supernova.networkswitch.service import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log +import com.supernova.networkswitch.R import com.supernova.networkswitch.domain.model.SimInfo import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.model.SubscriptionSelection @@ -128,27 +129,27 @@ class SimSwitchTileService : TileService() { if (simQuery is SimQueryResult.PermissionDenied) { tile.state = Tile.STATE_INACTIVE - tile.label = "SIM Select" - tile.subtitle = "Permission needed" + tile.label = getString(R.string.sim_switch) + tile.subtitle = getString(R.string.sim_tile_permission_needed) tile.updateTile() return } if (simQuery is SimQueryResult.Failed) { tile.state = Tile.STATE_INACTIVE - tile.label = "SIM Select" - tile.subtitle = "Unavailable" + tile.label = getString(R.string.sim_switch) + tile.subtitle = getString(R.string.sim_tile_unavailable) tile.updateTile() return } if (availableSims.size <= 1) { tile.state = Tile.STATE_INACTIVE - tile.label = "SIM Select" + tile.label = getString(R.string.sim_switch) tile.subtitle = if (availableSims.size == 1) { availableSims.first().displayName } else { - "No SIM detected" + getString(R.string.sim_tile_no_sim) } tile.updateTile() return @@ -158,17 +159,17 @@ class SimSwitchTileService : TileService() { tile.state = Tile.STATE_ACTIVE if (selectedSubId == SubscriptionSelection.AUTO) { - tile.label = "SIM: Auto" - tile.subtitle = "System default" + tile.label = getString(R.string.sim_tile_auto) + tile.subtitle = getString(R.string.sim_tile_auto_subtitle) } else { val simInfo = availableSims.find { it.subscriptionId == selectedSubId } if (simInfo != null) { - tile.label = "SIM: ${simInfo.displayName}" - tile.subtitle = "Tap to switch" + tile.label = getString(R.string.sim_tile_selected, simInfo.displayName) + tile.subtitle = getString(R.string.sim_tile_tap_to_switch) } else { // Selected SIM no longer available - tile.label = "SIM: Auto" - tile.subtitle = "Previous SIM removed" + tile.label = getString(R.string.sim_tile_auto) + tile.subtitle = getString(R.string.sim_tile_previous_removed) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e06a937..d94aa1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,4 +15,12 @@ Switching… Next: %1$s Tap to load + Permission needed + Unavailable + No SIM detected + SIM: Auto + System default + SIM: %1$s + Tap to switch + Previous SIM removed \ No newline at end of file From 6315616182f0810d9e0c42db434f784c1bad45ee Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sat, 22 Aug 2026 08:56:12 +0530 Subject: [PATCH 12/22] fix: refresh network mode alongside the compatibility check --- .../source/RootNetworkControlDataSource.kt | 12 +++- .../source/ShizukuNetworkControlDataSource.kt | 30 +++++----- .../presentation/viewmodel/MainViewModel.kt | 58 +++++++++---------- 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt index 26b1748..f36beeb 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt @@ -27,10 +27,16 @@ class RootNetworkControlDataSource @Inject constructor( private var isServiceConnected = false override suspend fun checkCompatibility(subId: Int): CompatibilityState { - return if (!Utils.isRootGranted()) { - CompatibilityState.PermissionDenied(com.supernova.networkswitch.domain.model.ControlMethod.ROOT) - } else { + if (!Utils.isRootGranted()) { + return CompatibilityState.PermissionDenied(com.supernova.networkswitch.domain.model.ControlMethod.ROOT) + } + + // Root access alone does not mean the root service is bound, and reads go + // through that binding. + return if (getNetworkController() != null) { CompatibilityState.Compatible + } else { + CompatibilityState.Incompatible("Root service unavailable") } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt index 3cf8e39..e4ba8d1 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt @@ -36,6 +36,8 @@ class ShizukuNetworkControlDataSource @Inject constructor( return try { // Check if Shizuku service is running if (!Shizuku.pingBinder()) { + // Any cached binder belongs to the process that just went away. + resetConnection() return CompatibilityState.Incompatible("Shizuku service not running") } @@ -49,8 +51,13 @@ class ShizukuNetworkControlDataSource @Inject constructor( if (permission != PackageManager.PERMISSION_GRANTED) { return CompatibilityState.PermissionDenied(com.supernova.networkswitch.domain.model.ControlMethod.SHIZUKU) } - - // Both service and permission checks passed + + // A reachable Shizuku with permission still does not mean the user service + // is bound, and reads go through that binding. + if (!ensureServiceBinding()) { + return CompatibilityState.Incompatible("Shizuku user service unavailable") + } + CompatibilityState.Compatible } catch (e: Exception) { CompatibilityState.Incompatible("Shizuku not available: ${e.message}") @@ -89,18 +96,6 @@ class ShizukuNetworkControlDataSource @Inject constructor( _isConnected.value = false } - /** - * Simple permission and service check without delays or complex logic - */ - private fun hasPermissionAndService(): Boolean { - return try { - // Only check if service is already connected, don't call Shizuku APIs that might block - userService != null && _isConnected.value - } catch (e: Exception) { - false - } - } - /** * Ensure service binding for actual operations (not compatibility checks) */ @@ -110,10 +105,13 @@ class ShizukuNetworkControlDataSource @Inject constructor( return false } - // If service is already connected, return true - if (userService != null && _isConnected.value) { + // onServiceDisconnected is not guaranteed to have run yet, so trust the binder + // itself rather than the connected flag. + val cached = userService + if (cached != null && _isConnected.value && cached.asBinder()?.pingBinder() == true) { return true } + resetConnection() // Bind service asynchronously with timeout return try { 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 40ec347..5c6cb88 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 @@ -16,6 +16,7 @@ import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.UpdateControlMethodUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject @@ -51,11 +52,12 @@ class MainViewModel @Inject constructor( var compatibilityState by mutableStateOf(CompatibilityState.Pending) private set + private var refreshJob: Job? = null + init { observeControlMethodPreference() loadToggleModeConfig() - checkCompatibility() - refreshNetworkState() + refresh() } /** @@ -66,9 +68,8 @@ class MainViewModel @Inject constructor( preferencesRepository.observeControlMethod().collectLatest { preferredMethod -> if (selectedMethod != preferredMethod) { selectedMethod = preferredMethod - checkCompatibility() loadToggleModeConfig() - refreshNetworkState() + refresh() } } } @@ -77,21 +78,32 @@ class MainViewModel @Inject constructor( viewModelScope.launch { preferencesRepository.observeToggleModeConfig().collectLatest { newConfig -> toggleModeConfig = newConfig - // Force UI update when config changes - refreshNetworkState() } } } /** - * Check compatibility using domain use case + * Re-read compatibility and, if the control method works, the current network mode. + * + * Both values are refreshed in one coroutine because the mode read is only + * meaningful once compatibility is confirmed. Every caller goes through here so the + * two cannot be updated independently and disagree. A refresh already in flight is + * cancelled, so the newest request wins. */ - private fun checkCompatibility() { - viewModelScope.launch { + private fun refresh() { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { compatibilityState = CompatibilityState.Pending - compatibilityState = checkCompatibilityUseCase() + val state = checkCompatibilityUseCase() + compatibilityState = state + currentNetworkMode = if (state is CompatibilityState.Compatible) readCurrentMode() else null } } + + private suspend fun readCurrentMode(): NetworkMode? { + val subId = getEffectiveSubscriptionIdUseCase() + return getCurrentNetworkModeUseCase(subId).getOrNull() + } /** * Switch control method @@ -106,15 +118,14 @@ class MainViewModel @Inject constructor( * Retry compatibility check */ fun retryCompatibilityCheck() { - checkCompatibility() + refresh() } /** * Refresh all data when app resumes */ fun refreshAllData() { - checkCompatibility() - refreshNetworkState() + refresh() } /** @@ -147,8 +158,8 @@ class MainViewModel @Inject constructor( currentNetworkMode = newMode } .onFailure { - // On failure, refresh state to get current status - refreshNetworkState() + // The mode may or may not have changed; re-read rather than guess. + refresh() } isLoading = false @@ -163,21 +174,4 @@ class MainViewModel @Inject constructor( return "Switch to ${nextMode.displayName}" } - /** - * Refresh current network state - */ - private fun refreshNetworkState() { - viewModelScope.launch { - // Use the user's selected subscription ID (or default if "Auto" selected) - val subId = getEffectiveSubscriptionIdUseCase() - - getCurrentNetworkModeUseCase(subId) - .onSuccess { mode -> - currentNetworkMode = mode - } - .onFailure { - currentNetworkMode = null - } - } - } } From e956470d89af59d26a0899f93488197fb4f3ddd9 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Thu, 27 Aug 2026 21:45:09 +0530 Subject: [PATCH 13/22] feat: add quick settings sim icon --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/res/drawable/ic_sim_card.xml | 18 ------------------ app/src/main/res/drawable/ic_sim_switch.xml | 10 ++++++++++ 3 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_sim_card.xml create mode 100644 app/src/main/res/drawable/ic_sim_switch.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ec9a0e..b156fac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,7 +71,7 @@ diff --git a/app/src/main/res/drawable/ic_sim_card.xml b/app/src/main/res/drawable/ic_sim_card.xml deleted file mode 100644 index 5c904a7..0000000 --- a/app/src/main/res/drawable/ic_sim_card.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_sim_switch.xml b/app/src/main/res/drawable/ic_sim_switch.xml new file mode 100644 index 0000000..125ba4f --- /dev/null +++ b/app/src/main/res/drawable/ic_sim_switch.xml @@ -0,0 +1,10 @@ + + + From e3a6d47024c0503a4593b6f4a549edb9a4864114 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sat, 29 Aug 2026 22:03:38 +0530 Subject: [PATCH 14/22] test: cover SimRepositoryImpl against a Robolectric SubscriptionManager --- app/build.gradle.kts | 1 + .../data/repository/SimRepositoryImplTest.kt | 310 ++++++++++++++++++ .../util/TestSubscriptionManagerShadow.kt | 65 ++++ gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 3 + 6 files changed, 394 insertions(+) create mode 100644 app/src/test/java/com/supernova/networkswitch/data/repository/SimRepositoryImplTest.kt create mode 100644 app/src/test/java/com/supernova/networkswitch/util/TestSubscriptionManagerShadow.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 73ee919..e0e2770 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/test/java/com/supernova/networkswitch/data/repository/SimRepositoryImplTest.kt b/app/src/test/java/com/supernova/networkswitch/data/repository/SimRepositoryImplTest.kt new file mode 100644 index 0000000..a2f7374 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/repository/SimRepositoryImplTest.kt @@ -0,0 +1,310 @@ +package com.supernova.networkswitch.data.repository + +import android.Manifest +import android.app.Application +import android.content.Context +import android.os.Looper +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult +import com.supernova.networkswitch.util.TestSubscriptionManagerShadow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowContextImpl +import org.robolectric.shadows.ShadowSubscriptionManager + +/** + * Unit tests for SimRepositoryImpl, driving the real SubscriptionManager call path + * through Robolectric rather than a mocked repository. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [TestSubscriptionManagerShadow::class]) +class SimRepositoryImplTest { + + private lateinit var application: Application + private lateinit var repository: SimRepositoryImpl + + @Before + fun setUp() { + TestSubscriptionManagerShadow.resetTestState() + application = RuntimeEnvironment.getApplication() + grantReadPhoneState() + repository = SimRepositoryImpl(application) + } + + // region getAvailableSimCards + + @Test + fun `should report permission denied without READ_PHONE_STATE`() = runTest { + denyReadPhoneState() + + assertEquals(SimQueryResult.PermissionDenied, repository.getAvailableSimCards()) + } + + @Test + fun `should report a device with no SIMs as loaded and empty`() = runTest { + setSubscriptions(emptyList()) + + assertEquals(SimQueryResult.Loaded(emptyList()), repository.getAvailableSimCards()) + } + + @Test + fun `should treat an absent subscription list as empty rather than as a failure`() = runTest { + assertEquals(SimQueryResult.Loaded(emptyList()), repository.getAvailableSimCards()) + } + + @Test + fun `should map a single SIM with its carrier name and one-based slot`() = runTest { + setSubscriptions(listOf(subscription(id = 7, slot = 0, name = "Airtel"))) + + assertEquals( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 7, simSlotIndex = 0, displayName = "Airtel (Slot 1)")) + ), + repository.getAvailableSimCards() + ) + } + + @Test + fun `should order SIMs by slot regardless of the platform's ordering`() = runTest { + setSubscriptions( + listOf( + subscription(id = 2, slot = 1, name = "Jio"), + subscription(id = 1, slot = 0, name = "Airtel") + ) + ) + + val loaded = repository.getAvailableSimCards() as SimQueryResult.Loaded + + assertEquals(listOf(0, 1), loaded.sims.map { it.simSlotIndex }) + assertEquals(listOf("Airtel (Slot 1)", "Jio (Slot 2)"), loaded.sims.map { it.displayName }) + } + + @Test + fun `should fall back to the slot number when the carrier name is missing`() = runTest { + setSubscriptions(listOf(subscription(id = 3, slot = 1, name = null))) + + assertEquals( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 3, simSlotIndex = 1, displayName = "SIM 2")) + ), + repository.getAvailableSimCards() + ) + } + + @Test + fun `should fall back to the slot number when the carrier name is blank`() = runTest { + setSubscriptions(listOf(subscription(id = 3, slot = 0, name = " "))) + + assertEquals( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 3, simSlotIndex = 0, displayName = "SIM 1")) + ), + repository.getAvailableSimCards() + ) + } + + @Test + fun `should omit the slot for a subscription with no physical slot`() = runTest { + setSubscriptions(listOf(subscription(id = 42, slot = -1, name = "eSIM Profile"))) + + assertEquals( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 42, simSlotIndex = -1, displayName = "eSIM Profile")) + ), + repository.getAvailableSimCards() + ) + } + + @Test + fun `should name a slotless unnamed subscription by its subscription id`() = runTest { + setSubscriptions(listOf(subscription(id = 42, slot = -1, name = null))) + + assertEquals( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 42, simSlotIndex = -1, displayName = "SIM 42")) + ), + repository.getAvailableSimCards() + ) + } + + @Test + fun `should report permission denied when the platform rejects the query`() = runTest { + TestSubscriptionManagerShadow.queryFailure = SecurityException("no access") + + assertEquals(SimQueryResult.PermissionDenied, repository.getAvailableSimCards()) + } + + @Test + fun `should report an unexpected query error as a failure`() = runTest { + val cause = IllegalStateException("telephony died") + TestSubscriptionManagerShadow.queryFailure = cause + + assertEquals(SimQueryResult.Failed(cause), repository.getAvailableSimCards()) + } + + @Test + fun `should report a failure when SubscriptionManager is unavailable`() = runTest { + removeSubscriptionService() + + val result = SimRepositoryImpl(application).getAvailableSimCards() + + assertTrue(result is SimQueryResult.Failed) + } + + // endregion + + // region observeAvailableSimCards + + @Test + fun `should emit the current SIMs as soon as the listener is registered`() = runTest { + setSubscriptions(listOf(subscription(id = 1, slot = 0, name = "Airtel"))) + val emissions = mutableListOf() + + val job = startObserving(emissions) + idleMainLooper() + + assertEquals( + listOf( + SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "Airtel (Slot 1)")) + ) + ), + emissions + ) + job.cancelAndJoin() + } + + @Test + @Config(sdk = [29]) + fun `should emit on the pre-R registration path too`() = runTest { + setSubscriptions(listOf(subscription(id = 1, slot = 0, name = "Airtel"))) + val emissions = mutableListOf() + + val job = startObserving(emissions) + idleMainLooper() + + assertEquals(1, emissions.size) + assertEquals(1, TestSubscriptionManagerShadow.registeredListeners.size) + job.cancelAndJoin() + } + + @Test + fun `should re-emit when the SIM set changes`() = runTest { + setSubscriptions(listOf(subscription(id = 1, slot = 0, name = "Airtel"))) + val emissions = mutableListOf() + val job = startObserving(emissions) + idleMainLooper() + + setSubscriptions( + listOf( + subscription(id = 1, slot = 0, name = "Airtel"), + subscription(id = 2, slot = 1, name = "Jio") + ) + ) + idleMainLooper() + + assertEquals(2, emissions.size) + assertEquals( + listOf("Airtel (Slot 1)", "Jio (Slot 2)"), + (emissions.last() as SimQueryResult.Loaded).sims.map { it.displayName } + ) + job.cancelAndJoin() + } + + @Test + fun `should detach the listener when collection stops`() = runTest { + setSubscriptions(listOf(subscription(id = 1, slot = 0, name = "Airtel"))) + val job = startObserving(mutableListOf()) + idleMainLooper() + val listener = TestSubscriptionManagerShadow.registeredListeners.single() + assertTrue(subscriptionShadow().hasOnSubscriptionsChangedListener(listener)) + + job.cancelAndJoin() + idleMainLooper() + + assertFalse(subscriptionShadow().hasOnSubscriptionsChangedListener(listener)) + } + + @Test + fun `should emit permission denied without registering a listener`() = runTest { + denyReadPhoneState() + val emissions = mutableListOf() + + val job = startObserving(emissions) + idleMainLooper() + + assertEquals(listOf(SimQueryResult.PermissionDenied), emissions) + assertTrue(TestSubscriptionManagerShadow.registeredListeners.isEmpty()) + job.cancelAndJoin() + } + + @Test + fun `should emit a failure without registering a listener when SubscriptionManager is unavailable`() = + runTest { + removeSubscriptionService() + val emissions = mutableListOf() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + SimRepositoryImpl(application).observeAvailableSimCards().collect { emissions += it } + } + idleMainLooper() + + assertEquals(1, emissions.size) + assertTrue(emissions.single() is SimQueryResult.Failed) + assertTrue(TestSubscriptionManagerShadow.registeredListeners.isEmpty()) + job.cancelAndJoin() + } + + // endregion + + private fun TestScope.startObserving(into: MutableList): Job = + launch(UnconfinedTestDispatcher(testScheduler)) { + repository.observeAvailableSimCards().collect { into += it } + } + + /** The looper is paused under Robolectric, so posted work runs only when idled. */ + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + private fun subscriptionShadow(): TestSubscriptionManagerShadow = + Shadow.extract(application.getSystemService(SubscriptionManager::class.java)) + + private fun setSubscriptions(subscriptions: List) = + subscriptionShadow().setActiveSubscriptionInfoList(subscriptions) + + private fun subscription(id: Int, slot: Int, name: String?): SubscriptionInfo = + ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder() + .setId(id) + .setSimSlotIndex(slot) + .setDisplayName(name) + .buildSubscriptionInfo() + + private fun grantReadPhoneState() = + shadowOf(application).grantPermissions(Manifest.permission.READ_PHONE_STATE) + + private fun denyReadPhoneState() = + shadowOf(application).denyPermissions(Manifest.permission.READ_PHONE_STATE) + + private fun removeSubscriptionService() { + val contextImpl: ShadowContextImpl = Shadow.extract(application.baseContext) + contextImpl.removeSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/util/TestSubscriptionManagerShadow.kt b/app/src/test/java/com/supernova/networkswitch/util/TestSubscriptionManagerShadow.kt new file mode 100644 index 0000000..1524a50 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/util/TestSubscriptionManagerShadow.kt @@ -0,0 +1,65 @@ +package com.supernova.networkswitch.util + +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.SubscriptionManager.OnSubscriptionsChangedListener +import java.util.concurrent.Executor +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowSubscriptionManager + +/** + * Extends Robolectric's SubscriptionManager shadow with the two things the stock shadow + * cannot express: a query that throws, and the identity of the listener the code under + * test registered. + * + * Register it with `@Config(shadows = [TestSubscriptionManagerShadow::class])`. + */ +@Implements(SubscriptionManager::class) +class TestSubscriptionManagerShadow : ShadowSubscriptionManager() { + + @Implementation + override fun getActiveSubscriptionInfoList(): MutableList { + queryFailure?.let { throw it } + return super.getActiveSubscriptionInfoList() + } + + @Implementation + override fun addOnSubscriptionsChangedListener(listener: OnSubscriptionsChangedListener) { + registeredListeners += listener + super.addOnSubscriptionsChangedListener(listener) + } + + @Implementation + override fun addOnSubscriptionsChangedListener( + executor: Executor, + listener: OnSubscriptionsChangedListener + ) { + registeredListeners += listener + super.addOnSubscriptionsChangedListener(executor, listener) + } + + companion object { + /** Thrown by every subscription query while set. */ + @JvmStatic + var queryFailure: RuntimeException? = null + + /** Every listener passed to either registration overload, in registration order. */ + @JvmStatic + val registeredListeners = mutableListOf() + + /** + * Must be called from the test's own setup: Robolectric only invokes @Resetter + * on its built-in shadows, not on one registered through `@Config(shadows = ...)`. + * + * Named apart from the superclass resetter, whose JVM signature it would + * otherwise collide with, so it has to clear the inherited state itself. + */ + @JvmStatic + fun resetTestState() { + queryFailure = null + registeredListeners.clear() + reset() + } + } +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..8b53169 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/690f22c7d5fabdba81db34b2bcbdfd40/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/19d7c2d18fdc600f184666745a0567c4/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/38d4fc46de6f02af9912aa3f66104600/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/34480c4bfcee5d3bd65f2a0592c5217b/redirect +toolchainVendor=JETBRAINS +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54a6d91..d9ebfea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ lifecycleProcess = "2.8.6" mockk = "1.13.10" coroutinesTest = "1.8.0" hiddenapibypass = "6.1" +robolectric = "4.16.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +52,7 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 67c3b35..baaf5da 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From d695cf4fdea5a356d83545a4b181f393851dbde8 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Tue, 1 Sep 2026 23:01:29 +0530 Subject: [PATCH 15/22] chore: refactor comments --- .../presentation/viewmodel/MainViewModel.kt | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) 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 5c6cb88..9fe9662 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 @@ -33,22 +33,18 @@ class MainViewModel @Inject constructor( private val preferencesRepository: PreferencesRepository ) : ViewModel() { - // Current control method selection var selectedMethod by mutableStateOf(ControlMethod.SHIZUKU) private set - // Current network mode var currentNetworkMode by mutableStateOf(null) private set - // Toggle mode configuration var toggleModeConfig by mutableStateOf(ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY)) private set var isLoading by mutableStateOf(false) private set - // Compatibility state using domain model var compatibilityState by mutableStateOf(CompatibilityState.Pending) private set @@ -60,9 +56,6 @@ class MainViewModel @Inject constructor( refresh() } - /** - * Observe control method preference changes - */ private fun observeControlMethodPreference() { viewModelScope.launch { preferencesRepository.observeControlMethod().collectLatest { preferredMethod -> @@ -74,7 +67,6 @@ class MainViewModel @Inject constructor( } } - // Also observe toggle mode config changes viewModelScope.launch { preferencesRepository.observeToggleModeConfig().collectLatest { newConfig -> toggleModeConfig = newConfig @@ -83,12 +75,11 @@ class MainViewModel @Inject constructor( } /** - * Re-read compatibility and, if the control method works, the current network mode. + * Sole writer of [compatibilityState] and [currentNetworkMode]. * - * Both values are refreshed in one coroutine because the mode read is only - * meaningful once compatibility is confirmed. Every caller goes through here so the - * two cannot be updated independently and disagree. A refresh already in flight is - * cancelled, so the newest request wins. + * The mode read is only meaningful once compatibility is confirmed, so both happen + * in one coroutine; routing every caller through here keeps the two from disagreeing. + * An in-flight refresh is cancelled, so the newest request wins. */ private fun refresh() { refreshJob?.cancel() @@ -105,52 +96,36 @@ class MainViewModel @Inject constructor( return getCurrentNetworkModeUseCase(subId).getOrNull() } - /** - * Switch control method - */ fun switchToMethod(method: ControlMethod) { viewModelScope.launch { updateControlMethodUseCase(method) } } - /** - * Retry compatibility check - */ fun retryCompatibilityCheck() { refresh() } - /** - * Refresh all data when app resumes - */ + /** Called on resume; the activity has no other signal that state may be stale. */ fun refreshAllData() { refresh() } - /** - * Load toggle mode configuration - */ private fun loadToggleModeConfig() { viewModelScope.launch { try { toggleModeConfig = getToggleModeConfigUseCase() } catch (e: Exception) { - // Use default configuration if loading fails toggleModeConfig = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) } } } - /** - * Toggle network mode using configurable modes - */ fun toggleNetworkMode() { if (isLoading) return isLoading = true viewModelScope.launch { - // Use the user's selected subscription ID (or default if "Auto" selected) val subId = getEffectiveSubscriptionIdUseCase() toggleNetworkModeUseCase(subId) @@ -158,7 +133,6 @@ class MainViewModel @Inject constructor( currentNetworkMode = newMode } .onFailure { - // The mode may or may not have changed; re-read rather than guess. refresh() } @@ -166,9 +140,6 @@ class MainViewModel @Inject constructor( } } - /** - * Get display text for current network mode and next toggle mode - */ fun getToggleButtonText(): String { val nextMode = toggleModeConfig.getNextMode() return "Switch to ${nextMode.displayName}" From fc9c37a74cc9944604718c9cb56a7b1ab7c0b725 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Tue, 1 Sep 2026 23:31:17 +0530 Subject: [PATCH 16/22] fix: derive the toggle target from the mode the radio is on The target came from a stored pointer shared by every SIM, so selecting a SIM already on mode B still reported and applied mode B. ToggleModeConfig now derives it from the current mode, which also keeps the tile correct when the mode is changed outside the app. --- .../data/source/PreferencesDataSource.kt | 16 +- .../domain/model/NetworkSwitchModels.kt | 22 ++- .../domain/usecase/NetworkUseCases.kt | 21 +-- .../presentation/viewmodel/MainViewModel.kt | 2 +- .../service/NetworkTileService.kt | 4 +- .../domain/model/ToggleModeConfigTest.kt | 94 +++++------ .../usecase/ToggleNetworkModeUseCaseTest.kt | 150 ++++++++---------- 7 files changed, 130 insertions(+), 179 deletions(-) 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 0323f04..d964c47 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 @@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.booleanPreferencesKey import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.NetworkMode @@ -25,14 +24,12 @@ class PreferencesDataSource @Inject constructor( private val CONTROL_METHOD_KEY = stringPreferencesKey("control_method") 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 private const val DEFAULT_SUBSCRIPTION_ID = SubscriptionSelection.AUTO } @@ -67,12 +64,10 @@ class PreferencesDataSource @Inject constructor( return dataStore.data.map { preferences -> val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value - val nextIsB = preferences[TOGGLE_NEXT_IS_B_KEY] ?: DEFAULT_NEXT_IS_B - val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - - ToggleModeConfig(modeA, modeB, nextIsB) + + ToggleModeConfig(modeA, modeB) }.first() } @@ -80,7 +75,6 @@ class PreferencesDataSource @Inject constructor( dataStore.edit { preferences -> preferences[TOGGLE_MODE_A_KEY] = config.modeA.value preferences[TOGGLE_MODE_B_KEY] = config.modeB.value - preferences[TOGGLE_NEXT_IS_B_KEY] = config.nextModeIsB } } @@ -88,12 +82,10 @@ class PreferencesDataSource @Inject constructor( return dataStore.data.map { preferences -> val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value - val nextIsB = preferences[TOGGLE_NEXT_IS_B_KEY] ?: DEFAULT_NEXT_IS_B - val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - - ToggleModeConfig(modeA, modeB, nextIsB) + + ToggleModeConfig(modeA, modeB) } } 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 e6f4e68..7d1ecdc 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 @@ -67,20 +67,16 @@ enum class NetworkMode(val displayName: String, val value: Int) { */ data class ToggleModeConfig( val modeA: NetworkMode, - val modeB: NetworkMode, - val nextModeIsB: Boolean = true + val modeB: NetworkMode ) { - fun getNextMode(): NetworkMode { - return if (nextModeIsB) modeB else modeA - } - - fun getCurrentMode(): NetworkMode { - return if (nextModeIsB) modeA else modeB - } - - fun toggle(): ToggleModeConfig { - return copy(nextModeIsB = !nextModeIsB) - } + /** + * The mode a toggle moves to, given the mode the radio is on. + * + * [actual] is null when the mode is unreadable, and is outside this pair when the + * SIM was configured elsewhere. Both target [modeA]. + */ + fun nextAfter(actual: NetworkMode?): NetworkMode = + if (actual == modeA) modeB else modeA } sealed class CompatibilityState { 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 e51e94b..849a80b 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 @@ -31,20 +31,13 @@ class ToggleNetworkModeUseCase @Inject constructor( suspend operator fun invoke(subId: Int): Result { return try { val toggleConfig = preferencesRepository.getToggleModeConfig() - - // Get the next mode to switch to (no current mode detection needed) - val targetMode = toggleConfig.getNextMode() - - // Set the network mode - val result = networkControlRepository.setNetworkMode(subId, targetMode) - - if (result.isSuccess) { - // Update the toggle state for next time - val newConfig = toggleConfig.toggle() - preferencesRepository.setToggleModeConfig(newConfig) - } - - result.map { targetMode } + + // Read the mode this SIM is actually on: the target depends on it, and it + // may have been changed by another SIM's toggle or outside the app. + val actualMode = networkControlRepository.getCurrentNetworkMode(subId) + val targetMode = toggleConfig.nextAfter(actualMode) + + networkControlRepository.setNetworkMode(subId, targetMode).map { targetMode } } catch (e: Exception) { Result.failure(e) } 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 9fe9662..3999e56 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 @@ -141,7 +141,7 @@ class MainViewModel @Inject constructor( } fun getToggleButtonText(): String { - val nextMode = toggleModeConfig.getNextMode() + val nextMode = toggleModeConfig.nextAfter(currentNetworkMode) return "Switch to ${nextMode.displayName}" } 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 9d57467..3b0f7d7 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -117,10 +117,10 @@ class NetworkTileService : TileService() { if (config != null) { tile.state = Tile.STATE_ACTIVE - tile.label = (currentNetworkMode ?: config.getCurrentMode()).displayName + tile.label = currentNetworkMode?.displayName ?: getString(R.string.tile_not_loaded) tile.subtitle = getString( R.string.tile_next_mode, - config.getNextMode().displayName, + config.nextAfter(currentNetworkMode).displayName, ) } else { tile.state = Tile.STATE_INACTIVE diff --git a/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt index 3e10cdd..abd8a69 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt @@ -7,64 +7,52 @@ import org.junit.Test * Unit tests for ToggleModeConfig */ class ToggleModeConfigTest { - + + private val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) + @Test - fun testGetNextMode() { - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - - // When nextModeIsB = true, should return modeB - assertEquals(NetworkMode.NR_ONLY, config.getNextMode()) - - val config2 = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = false) - - // When nextModeIsB = false, should return modeA - assertEquals(NetworkMode.LTE_ONLY, config2.getNextMode()) + fun `should target mode B when the radio is on mode A`() { + assertEquals(NetworkMode.NR_ONLY, config.nextAfter(NetworkMode.LTE_ONLY)) } - + @Test - fun testGetCurrentMode() { - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - - // When nextModeIsB = true, current should be modeA - assertEquals(NetworkMode.LTE_ONLY, config.getCurrentMode()) - - val config2 = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = false) - - // When nextModeIsB = false, current should be modeB - assertEquals(NetworkMode.NR_ONLY, config2.getCurrentMode()) + fun `should target mode A when the radio is on mode B`() { + assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_ONLY)) } - + @Test - fun testToggle() { - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - - val toggledConfig = config.toggle() - - // Should flip the nextModeIsB flag - assertFalse(toggledConfig.nextModeIsB) - assertEquals(NetworkMode.LTE_ONLY, toggledConfig.modeA) - assertEquals(NetworkMode.NR_ONLY, toggledConfig.modeB) + fun `should target mode A when the radio mode is unknown`() { + assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(null)) } - + @Test - fun testAlternatingToggle() { - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - - // First toggle: next is NR_ONLY, current is LTE_ONLY - assertEquals(NetworkMode.NR_ONLY, config.getNextMode()) - assertEquals(NetworkMode.LTE_ONLY, config.getCurrentMode()) - - val afterFirstToggle = config.toggle() - - // After toggle: next is LTE_ONLY, current is NR_ONLY - assertEquals(NetworkMode.LTE_ONLY, afterFirstToggle.getNextMode()) - assertEquals(NetworkMode.NR_ONLY, afterFirstToggle.getCurrentMode()) - - val afterSecondToggle = afterFirstToggle.toggle() - - // After second toggle: back to original state - assertEquals(config.nextModeIsB, afterSecondToggle.nextModeIsB) - assertEquals(NetworkMode.NR_ONLY, afterSecondToggle.getNextMode()) - assertEquals(NetworkMode.LTE_ONLY, afterSecondToggle.getCurrentMode()) + fun `should target mode A when the radio is on a mode outside the pair`() { + assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_LTE_GSM_WCDMA)) + } + + /** Two SIMs on different modes get different targets from one shared config. */ + @Test + fun `should give each radio mode its own target from the same config`() { + assertEquals(NetworkMode.NR_ONLY, config.nextAfter(NetworkMode.LTE_ONLY)) + assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_ONLY)) + } + + @Test + fun `should alternate when the radio follows the reported target`() { + var actual: NetworkMode? = NetworkMode.LTE_ONLY + + actual = config.nextAfter(actual) + assertEquals(NetworkMode.NR_ONLY, actual) + + actual = config.nextAfter(actual) + assertEquals(NetworkMode.LTE_ONLY, actual) + } + + @Test + fun `should handle a config whose two modes are identical`() { + val same = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.LTE_ONLY) + + assertEquals(NetworkMode.LTE_ONLY, same.nextAfter(NetworkMode.LTE_ONLY)) + assertEquals(NetworkMode.LTE_ONLY, same.nextAfter(null)) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt index 8d53ff7..a95b513 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt @@ -13,122 +13,104 @@ import org.junit.Before import org.junit.Test /** - * Unit tests for ToggleNetworkModeUseCase with alternating logic + * Unit tests for ToggleNetworkModeUseCase */ class ToggleNetworkModeUseCaseTest { - + private lateinit var networkControlRepository: NetworkControlRepository private lateinit var preferencesRepository: PreferencesRepository private lateinit var useCase: ToggleNetworkModeUseCase - + private val testSubId = 1 - + private val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) + @Before fun setUp() { networkControlRepository = mockk() preferencesRepository = mockk() useCase = ToggleNetworkModeUseCase(networkControlRepository, preferencesRepository) + coEvery { preferencesRepository.getToggleModeConfig() } returns config } - + @Test - fun `should switch to mode B when nextModeIsB is true`() = runTest { - // Given - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - coEvery { preferencesRepository.getToggleModeConfig() } returns config + fun `should switch to mode B when the SIM is on mode A`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.success(Unit) - - val updatedConfig = config.toggle() // After successful switch, toggle the state - coEvery { preferencesRepository.setToggleModeConfig(updatedConfig) } returns Unit - - // When + val result = useCase(testSubId) - - // Then - assertTrue(result.isSuccess) + assertEquals(NetworkMode.NR_ONLY, result.getOrNull()) coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } - coVerify { preferencesRepository.setToggleModeConfig(updatedConfig) } } - + @Test - fun `should switch to mode A when nextModeIsB is false`() = runTest { - // Given - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = false) - coEvery { preferencesRepository.getToggleModeConfig() } returns config + fun `should switch to mode A when the SIM is on mode B`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.NR_ONLY coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) - - val updatedConfig = config.toggle() // After successful switch, toggle the state - coEvery { preferencesRepository.setToggleModeConfig(updatedConfig) } returns Unit - - // When + val result = useCase(testSubId) - - // Then - assertTrue(result.isSuccess) + assertEquals(NetworkMode.LTE_ONLY, result.getOrNull()) coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } - coVerify { preferencesRepository.setToggleModeConfig(updatedConfig) } } - + + /** A SIM already on mode B must not be told to switch to mode B. */ @Test - fun `should handle alternating sequence correctly`() = runTest { - // Given - val initialConfig = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - coEvery { preferencesRepository.getToggleModeConfig() } returns initialConfig - - // First call should switch to NR_ONLY - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.success(Unit) - val afterFirstToggle = initialConfig.toggle() - coEvery { preferencesRepository.setToggleModeConfig(afterFirstToggle) } returns Unit - - // When - val firstResult = useCase(testSubId) - - // Then - assertTrue(firstResult.isSuccess) - assertEquals(NetworkMode.NR_ONLY, firstResult.getOrNull()) - - // The next call should switch to LTE_ONLY - coEvery { preferencesRepository.getToggleModeConfig() } returns afterFirstToggle + fun `should target each SIM from its own mode`() = runTest { + val otherSubId = 2 + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { networkControlRepository.getCurrentNetworkMode(otherSubId) } returns NetworkMode.NR_ONLY + coEvery { networkControlRepository.setNetworkMode(any(), any()) } returns Result.success(Unit) + + assertEquals(NetworkMode.NR_ONLY, useCase(testSubId).getOrNull()) + assertEquals(NetworkMode.LTE_ONLY, useCase(otherSubId).getOrNull()) + } + + @Test + fun `should target mode A when the current mode cannot be read`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns null + coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) + + assertEquals(NetworkMode.LTE_ONLY, useCase(testSubId).getOrNull()) + } + + @Test + fun `should target mode A when the SIM is on a mode outside the pair`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.NR_LTE_GSM_WCDMA coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) - val afterSecondToggle = afterFirstToggle.toggle() - coEvery { preferencesRepository.setToggleModeConfig(afterSecondToggle) } returns Unit - - val secondResult = useCase(testSubId) - - assertTrue(secondResult.isSuccess) - assertEquals(NetworkMode.LTE_ONLY, secondResult.getOrNull()) + + assertEquals(NetworkMode.LTE_ONLY, useCase(testSubId).getOrNull()) } - + @Test - fun `should return failure when setNetworkMode fails and not update config`() = runTest { - // Given - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - coEvery { preferencesRepository.getToggleModeConfig() } returns config - - val exception = RuntimeException("Network mode not supported") - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.failure(exception) - - // When + fun `should propagate a failure to apply the mode`() = runTest { + val failure = RuntimeException("binder died") + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.failure(failure) + val result = useCase(testSubId) - - // Then + assertTrue(result.isFailure) - assertEquals(exception, result.exceptionOrNull()) - // Config should not be updated when network mode change fails - coVerify(exactly = 0) { preferencesRepository.setToggleModeConfig(any()) } + assertEquals(failure, result.exceptionOrNull()) } - + @Test - fun `should handle repository exception`() = runTest { - // Given - coEvery { preferencesRepository.getToggleModeConfig() } throws RuntimeException("Repository error") - - // When + fun `should report a thrown error as a failure`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } throws IllegalStateException("no service") + val result = useCase(testSubId) - - // Then + assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is RuntimeException) } -} \ No newline at end of file + + /** The target is derived from the radio on each call, so a toggle persists nothing. */ + @Test + fun `should not write the toggle configuration`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { networkControlRepository.setNetworkMode(any(), any()) } returns Result.success(Unit) + + useCase(testSubId) + + coVerify(exactly = 0) { preferencesRepository.setToggleModeConfig(any()) } + } +} From d0a2b2757a0582882fd1009f7b8c83b5823224f6 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Wed, 2 Sep 2026 09:19:26 +0530 Subject: [PATCH 17/22] fix: bound the Shizuku bind and fail without a binder --- .../networkswitch/ExampleInstrumentedTest.kt | 2 +- .../source/ShizukuNetworkControlDataSource.kt | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/androidTest/java/com/supernova/networkswitch/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/supernova/networkswitch/ExampleInstrumentedTest.kt index 824a901..a754752 100644 --- a/app/src/androidTest/java/com/supernova/networkswitch/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/supernova/networkswitch/ExampleInstrumentedTest.kt @@ -18,7 +18,7 @@ class NetworkSwitchInstrumentedTest { @Test fun useAppContext() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.supernova.networkswitch", appContext.packageName) + assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName) } @Test diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt index e4ba8d1..586c004 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt @@ -11,7 +11,9 @@ import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.service.ShizukuControllerService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku import javax.inject.Inject import javax.inject.Singleton @@ -24,12 +26,16 @@ class ShizukuNetworkControlDataSource @Inject constructor( @ApplicationContext private val context: Context ) : NetworkControlDataSource { + @Volatile private var userService: IShizukuController? = null private val _isConnected = MutableStateFlow(false) val isConnected = _isConnected.asStateFlow() private companion object { private const val SHIZUKU_PERMISSION_REQUEST_ID = 8 + + /** bindUserService never calls back if the service cannot start. */ + private const val BIND_TIMEOUT_MS = 5_000L } override suspend fun checkCompatibility(subId: Int): CompatibilityState { @@ -79,16 +85,15 @@ class ShizukuNetworkControlDataSource @Inject constructor( override suspend fun setNetworkMode(subId: Int, mode: NetworkMode) { if (ensureServiceBinding()) { - try { - userService?.setNetworkMode(subId, mode.value) - } catch (e: Exception) { - throw e - } + val service = userService + ?: throw IllegalStateException("Shizuku user service unavailable") + service.setNetworkMode(subId, mode.value) } else { throw SecurityException("Shizuku permission not granted or service binding failed") } } + override fun isConnected(): Boolean = _isConnected.value override fun resetConnection() { @@ -113,8 +118,8 @@ class ShizukuNetworkControlDataSource @Inject constructor( } resetConnection() - // Bind service asynchronously with timeout return try { + withTimeoutOrNull(BIND_TIMEOUT_MS) { suspendCancellableCoroutine { continuation -> val args = Shizuku.UserServiceArgs(ComponentName(context, ShizukuControllerService::class.java)) .processNameSuffix("service") @@ -143,11 +148,6 @@ class ShizukuNetworkControlDataSource @Inject constructor( } } - continuation.invokeOnCancellation { - userService = null - _isConnected.value = false - } - try { Shizuku.bindUserService(args, serviceConnection) } catch (e: Exception) { @@ -156,6 +156,9 @@ class ShizukuNetworkControlDataSource @Inject constructor( } } } + } ?: false + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _isConnected.value = false false From ff6b90f412e20a7638bc11f4c3d41c8addaaf11b Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Thu, 3 Sep 2026 22:10:38 +0530 Subject: [PATCH 18/22] feat: store the toggle configuration per SIM --- .../NetworkControlRepositoryImpl.kt | 4 + .../repository/PreferencesRepositoryImpl.kt | 12 +- .../data/source/PreferencesDataSource.kt | 51 +++++---- .../source/RootNetworkControlDataSource.kt | 2 + .../domain/model/NetworkSwitchModels.kt | 13 +++ .../domain/repository/Repositories.kt | 6 +- .../domain/usecase/NetworkUseCases.kt | 17 +-- .../presentation/viewmodel/MainViewModel.kt | 29 ++--- .../viewmodel/NetworkModeConfigViewModel.kt | 8 +- .../service/NetworkTileService.kt | 32 +++++- app/src/main/res/xml/backup_rules.xml | 13 ++- .../main/res/xml/data_extraction_rules.xml | 21 ++-- .../source/ToggleModeConfigStorageTest.kt | 103 ++++++++++++++++++ .../usecase/ToggleNetworkModeUseCaseTest.kt | 4 +- .../util/InMemoryPreferencesDataStore.kt | 31 ++++++ 15 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt create mode 100644 app/src/test/java/com/supernova/networkswitch/util/InMemoryPreferencesDataStore.kt diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index 0abcf24..606e38d 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -5,6 +5,9 @@ import com.supernova.networkswitch.data.source.RootNetworkControlDataSource import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -25,6 +28,7 @@ class NetworkControlRepositoryImpl @Inject constructor( return getDataSource(method).checkCompatibility(subId) } + override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val method = preferencesRepository.getControlMethod() diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt index 8501465..558db73 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt @@ -28,16 +28,16 @@ class PreferencesRepositoryImpl @Inject constructor( return preferencesDataSource.observeControlMethod() } - override suspend fun getToggleModeConfig(): ToggleModeConfig { - return preferencesDataSource.getToggleModeConfig() + override suspend fun getToggleModeConfig(simKey: String): ToggleModeConfig { + return preferencesDataSource.getToggleModeConfig(simKey) } - override suspend fun setToggleModeConfig(config: ToggleModeConfig) { - preferencesDataSource.setToggleModeConfig(config) + override suspend fun setToggleModeConfig(simKey: String, config: ToggleModeConfig) { + preferencesDataSource.setToggleModeConfig(simKey, config) } - override fun observeToggleModeConfig(): Flow { - return preferencesDataSource.observeToggleModeConfig() + override fun observeToggleModeConfig(simKey: String): Flow { + return preferencesDataSource.observeToggleModeConfig(simKey) } override suspend fun getSelectedSubscriptionId(): Int { 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 d964c47..703f212 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 @@ -10,6 +10,7 @@ import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -22,8 +23,9 @@ class PreferencesDataSource @Inject constructor( companion object { private val CONTROL_METHOD_KEY = stringPreferencesKey("control_method") - private val TOGGLE_MODE_A_KEY = intPreferencesKey("toggle_mode_a") - private val TOGGLE_MODE_B_KEY = intPreferencesKey("toggle_mode_b") + + private fun modeAKey(simKey: String) = intPreferencesKey("toggle_mode_a_$simKey") + private fun modeBKey(simKey: String) = intPreferencesKey("toggle_mode_b_$simKey") private val SELECTED_SUBSCRIPTION_ID_KEY = intPreferencesKey("selected_subscription_id") private const val DEFAULT_CONTROL_METHOD = "SHIZUKU" @@ -57,37 +59,34 @@ class PreferencesDataSource @Inject constructor( fun observeControlMethod(): Flow { return dataStore.data.map { preferences -> parseControlMethod(preferences[CONTROL_METHOD_KEY]) - } + }.distinctUntilChanged() } - suspend fun getToggleModeConfig(): ToggleModeConfig { - return dataStore.data.map { preferences -> - val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value - val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value - val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A - val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - - ToggleModeConfig(modeA, modeB) - }.first() + suspend fun getToggleModeConfig(simKey: String): ToggleModeConfig { + return observeToggleModeConfig(simKey).first() } - - suspend fun setToggleModeConfig(config: ToggleModeConfig) { + + suspend fun setToggleModeConfig(simKey: String, config: ToggleModeConfig) { dataStore.edit { preferences -> - preferences[TOGGLE_MODE_A_KEY] = config.modeA.value - preferences[TOGGLE_MODE_B_KEY] = config.modeB.value + preferences[modeAKey(simKey)] = config.modeA.value + preferences[modeBKey(simKey)] = config.modeB.value } } - - fun observeToggleModeConfig(): Flow { - return dataStore.data.map { preferences -> - val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value - val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value - val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A - val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - ToggleModeConfig(modeA, modeB) - } + fun observeToggleModeConfig(simKey: String): Flow { + return dataStore.data.map { readToggleModeConfig(it, simKey) }.distinctUntilChanged() } + + private fun readToggleModeConfig(preferences: Preferences, simKey: String): ToggleModeConfig { + val modeAValue = preferences[modeAKey(simKey)] ?: DEFAULT_MODE_A.value + val modeBValue = preferences[modeBKey(simKey)] ?: DEFAULT_MODE_B.value + + return ToggleModeConfig( + NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A, + NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B + ) + } + /** * Get the selected subscription ID for the SIM card @@ -115,6 +114,6 @@ class PreferencesDataSource @Inject constructor( fun observeSelectedSubscriptionId(): Flow { return dataStore.data.map { preferences -> preferences[SELECTED_SUBSCRIPTION_ID_KEY] ?: DEFAULT_SUBSCRIPTION_ID - } + }.distinctUntilChanged() } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt index f36beeb..0efae22 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt @@ -8,6 +8,7 @@ import android.os.IBinder import com.supernova.networkswitch.IRootController import com.supernova.networkswitch.service.RootNetworkControllerService import com.supernova.networkswitch.domain.model.CompatibilityState +import kotlinx.coroutines.CancellationException import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.util.Utils import com.topjohnwu.superuser.ipc.RootService @@ -55,6 +56,7 @@ class RootNetworkControlDataSource @Inject constructor( controller?.setNetworkMode(subId, mode.value) } + override fun isConnected(): Boolean = isServiceConnected override fun resetConnection() { 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 7d1ecdc..d74aa7a 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 @@ -109,6 +109,19 @@ object SubscriptionSelection { const val AUTO = -1 } +/** + * Storage keys for per-SIM configuration. + */ +object SimConfigKey { + /** + * Keyed on the subscription ID, which Android assigns as the row id of its SIM + * table. Rows persist, so a SIM normally keeps its id across removal and + * reinsertion; a rebuilt table or a re-downloaded eSIM profile gets a new id and + * that SIM falls back to defaults. + */ + fun forSubscription(subId: Int): String = "sub_$subId" +} + /** * Outcome of querying the device for SIM cards. * 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 ef6cffb..7beb8e9 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 @@ -56,17 +56,17 @@ interface PreferencesRepository { /** * Get toggle mode configuration */ - suspend fun getToggleModeConfig(): ToggleModeConfig + suspend fun getToggleModeConfig(simKey: String): ToggleModeConfig /** * Set toggle mode configuration */ - suspend fun setToggleModeConfig(config: ToggleModeConfig) + suspend fun setToggleModeConfig(simKey: String, config: ToggleModeConfig) /** * Observe toggle mode configuration changes */ - fun observeToggleModeConfig(): Flow + fun observeToggleModeConfig(simKey: String): Flow /** * Get the selected subscription ID for the SIM card 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 849a80b..6c86f91 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 @@ -5,6 +5,7 @@ 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.SimConfigKey import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.ToggleModeConfig @@ -30,7 +31,7 @@ class ToggleNetworkModeUseCase @Inject constructor( ) { suspend operator fun invoke(subId: Int): Result { return try { - val toggleConfig = preferencesRepository.getToggleModeConfig() + val toggleConfig = preferencesRepository.getToggleModeConfig(SimConfigKey.forSubscription(subId)) // Read the mode this SIM is actually on: the target depends on it, and it // may have been changed by another SIM's toggle or outside the app. @@ -70,25 +71,19 @@ class UpdateControlMethodUseCase @Inject constructor( } } -/** - * Use case for getting toggle mode configuration - */ class GetToggleModeConfigUseCase @Inject constructor( private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(): ToggleModeConfig { - return preferencesRepository.getToggleModeConfig() + suspend operator fun invoke(subId: Int): ToggleModeConfig { + return preferencesRepository.getToggleModeConfig(SimConfigKey.forSubscription(subId)) } } -/** - * Use case for updating toggle mode configuration - */ class UpdateToggleModeConfigUseCase @Inject constructor( private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(config: ToggleModeConfig) { - preferencesRepository.setToggleModeConfig(config) + suspend operator fun invoke(subId: Int, config: ToggleModeConfig) { + preferencesRepository.setToggleModeConfig(SimConfigKey.forSubscription(subId), config) } } 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 3999e56..9c07d45 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 @@ -8,20 +8,25 @@ 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.NetworkMode +import com.supernova.networkswitch.domain.model.SimConfigKey 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 import com.supernova.networkswitch.domain.repository.PreferencesRepository import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest import javax.inject.Inject import dagger.hilt.android.lifecycle.HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class MainViewModel @Inject constructor( private val checkCompatibilityUseCase: CheckCompatibilityUseCase, @@ -29,7 +34,6 @@ class MainViewModel @Inject constructor( private val getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase, private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, - private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, private val preferencesRepository: PreferencesRepository ) : ViewModel() { @@ -50,9 +54,9 @@ class MainViewModel @Inject constructor( private var refreshJob: Job? = null + init { observeControlMethodPreference() - loadToggleModeConfig() refresh() } @@ -61,16 +65,17 @@ class MainViewModel @Inject constructor( preferencesRepository.observeControlMethod().collectLatest { preferredMethod -> if (selectedMethod != preferredMethod) { selectedMethod = preferredMethod - loadToggleModeConfig() refresh() } } } viewModelScope.launch { - preferencesRepository.observeToggleModeConfig().collectLatest { newConfig -> - toggleModeConfig = newConfig - } + preferencesRepository.observeSelectedSubscriptionId() + .mapLatest { SimConfigKey.forSubscription(getEffectiveSubscriptionIdUseCase()) } + .distinctUntilChanged() + .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } + .collectLatest { newConfig -> toggleModeConfig = newConfig } } } @@ -111,16 +116,6 @@ class MainViewModel @Inject constructor( refresh() } - private fun loadToggleModeConfig() { - viewModelScope.launch { - try { - toggleModeConfig = getToggleModeConfigUseCase() - } catch (e: Exception) { - toggleModeConfig = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) - } - } - } - fun toggleNetworkMode() { if (isLoading) return diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt index b27cfcb..29c818b 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.usecase.UpdateToggleModeConfigUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,7 +20,8 @@ import javax.inject.Inject @HiltViewModel class NetworkModeConfigViewModel @Inject constructor( private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, - private val updateToggleModeConfigUseCase: UpdateToggleModeConfigUseCase + private val updateToggleModeConfigUseCase: UpdateToggleModeConfigUseCase, + private val getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase ) : ViewModel() { private val _currentConfig = MutableStateFlow( @@ -40,7 +42,7 @@ class NetworkModeConfigViewModel @Inject constructor( private fun loadCurrentConfiguration() { viewModelScope.launch { try { - val config = getToggleModeConfigUseCase() + val config = getToggleModeConfigUseCase(getEffectiveSubscriptionId()) _currentConfig.value = config } catch (e: Exception) { // Use default configuration if loading fails @@ -65,7 +67,7 @@ class NetworkModeConfigViewModel @Inject constructor( _isLoading.value = true viewModelScope.launch { try { - updateToggleModeConfigUseCase(_currentConfig.value) + updateToggleModeConfigUseCase(getEffectiveSubscriptionId(), _currentConfig.value) _configSaved.value = true } catch (e: Exception) { // Handle error (could show toast or snackbar) 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 3b0f7d7..c9dd6fb 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -5,21 +5,29 @@ import android.service.quicksettings.TileService import android.util.Log import com.supernova.networkswitch.R import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.SimConfigKey import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withTimeout import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @AndroidEntryPoint class NetworkTileService : TileService() { @@ -41,24 +49,38 @@ class NetworkTileService : TileService() { * update the tile. */ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var listeningJob: Job? = null private var currentNetworkMode: NetworkMode? = null private var toggleConfig: ToggleModeConfig? = null override fun onStartListening() { super.onStartListening() - serviceScope.launch { + listeningJob?.cancel() + listeningJob = serviceScope.launch { try { - preferencesRepository.observeToggleModeConfig().collect { newConfig -> - toggleConfig = newConfig - refreshNetworkState() - } + preferencesRepository.observeSelectedSubscriptionId() + .mapLatest { SimConfigKey.forSubscription(getEffectiveSubscriptionIdUseCase()) } + .distinctUntilChanged() + .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } + .collect { newConfig -> + toggleConfig = newConfig + refreshNetworkState() + } + } catch (_: CancellationException) { + // Expected when the tile stops listening } catch (e: Exception) { Log.e(TAG, "Tile: Failed to observe toggle config", e) } } } + override fun onStopListening() { + super.onStopListening() + listeningJob?.cancel() + listeningJob = null + } + override fun onClick() { super.onClick() diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index cc4c8b5..2f29796 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,9 +6,10 @@ See https://developer.android.com/about/versions/12/backup-restore --> - - - - - - \ No newline at end of file + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 58bf46e..e9c18b5 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,18 +5,15 @@ --> - - - - - + + - + - - - - - + - \ No newline at end of file + diff --git a/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt b/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt new file mode 100644 index 0000000..9ac5219 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt @@ -0,0 +1,103 @@ +package com.supernova.networkswitch.data.source + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.util.InMemoryPreferencesDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Per-SIM toggle configuration storage. + */ +class ToggleModeConfigStorageTest { + + private lateinit var dataStore: InMemoryPreferencesDataStore + private lateinit var source: PreferencesDataSource + + + private val simA = "sim-a" + private val simB = "sim-b" + + private val defaults = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) + + @Before + fun setUp() { + dataStore = InMemoryPreferencesDataStore() + source = PreferencesDataSource(dataStore) + } + + + @Test + fun `should default when nothing is stored`() = runTest { + assertEquals(defaults, source.getToggleModeConfig(simA)) + } + + @Test + fun `should keep each SIM's configuration separate`() = runTest { + source.setToggleModeConfig(simA, ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY)) + source.setToggleModeConfig(simB, ToggleModeConfig(NetworkMode.WCDMA_ONLY, NetworkMode.NR_ONLY)) + + assertEquals( + ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY), + source.getToggleModeConfig(simA) + ) + assertEquals( + ToggleModeConfig(NetworkMode.WCDMA_ONLY, NetworkMode.NR_ONLY), + source.getToggleModeConfig(simB) + ) + } + + + + + + + + + + + @Test + fun `should emit again when the same SIM's configuration changes`() = runTest { + val seen = mutableListOf() + val job = launch { source.observeToggleModeConfig(simA).toList(seen) } + runCurrent() + + source.setToggleModeConfig(simA, ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY)) + runCurrent() + job.cancel() + + assertEquals(defaults, seen.first()) + assertEquals(ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY), seen.last()) + } + + + + + @Test + fun `should observe writes for the same SIM`() = runTest { + source.setToggleModeConfig(simA, ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY)) + + assertEquals( + ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY), + source.observeToggleModeConfig(simA).first() + ) + } + + @Test + fun `should fall back to defaults for an unrecognised stored mode value`() = runTest { + dataStore.edit { + it[intPreferencesKey("toggle_mode_a_$simA")] = 9999 + it[intPreferencesKey("toggle_mode_b_$simA")] = 9999 + } + + assertEquals(defaults, source.getToggleModeConfig(simA)) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt index a95b513..8b11d46 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt @@ -29,7 +29,7 @@ class ToggleNetworkModeUseCaseTest { networkControlRepository = mockk() preferencesRepository = mockk() useCase = ToggleNetworkModeUseCase(networkControlRepository, preferencesRepository) - coEvery { preferencesRepository.getToggleModeConfig() } returns config + coEvery { preferencesRepository.getToggleModeConfig(any()) } returns config } @Test @@ -111,6 +111,6 @@ class ToggleNetworkModeUseCaseTest { useCase(testSubId) - coVerify(exactly = 0) { preferencesRepository.setToggleModeConfig(any()) } + coVerify(exactly = 0) { preferencesRepository.setToggleModeConfig(any(), any()) } } } diff --git a/app/src/test/java/com/supernova/networkswitch/util/InMemoryPreferencesDataStore.kt b/app/src/test/java/com/supernova/networkswitch/util/InMemoryPreferencesDataStore.kt new file mode 100644 index 0000000..56e9643 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/util/InMemoryPreferencesDataStore.kt @@ -0,0 +1,31 @@ +package com.supernova.networkswitch.util + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory DataStore for tests that need real read-modify-write behaviour, which a + * mocked DataStore cannot express. + */ +class InMemoryPreferencesDataStore( + initial: Preferences = emptyPreferences() +) : DataStore { + + private val state = MutableStateFlow(initial) + private val writeLock = Mutex() + + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences + ): Preferences = writeLock.withLock { + val updated = transform(state.value) + state.value = updated + updated + } +} From 809639d62b2e6eea923e2bf64b7f1121c2892497 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 20 Sep 2026 01:31:23 +0530 Subject: [PATCH 19/22] feat: group the common network modes for selection --- .../domain/model/NetworkSwitchModels.kt | 15 ++++++++ .../domain/model/NetworkModeGroupingTest.kt | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/model/NetworkModeGroupingTest.kt 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 d74aa7a..27ecc52 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 @@ -59,6 +59,21 @@ enum class NetworkMode(val displayName: String, val value: Int) { fun fromValue(value: Int): NetworkMode? { return values().find { it.value == value } } + + /** + * The modes worth offering first: the full list is RIL terminology covering + * radios most devices do not have. + */ + val common: List = listOf( + GSM_ONLY, + WCDMA_ONLY, + LTE_ONLY, + NR_ONLY, + NR_LTE + ) + + /** The modes not in [common], in declaration order. */ + val advanced: List = entries - common.toSet() } } diff --git a/app/src/test/java/com/supernova/networkswitch/domain/model/NetworkModeGroupingTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/model/NetworkModeGroupingTest.kt new file mode 100644 index 0000000..37b313c --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/model/NetworkModeGroupingTest.kt @@ -0,0 +1,34 @@ +package com.supernova.networkswitch.domain.model + +import org.junit.Assert.* +import org.junit.Test + +class NetworkModeGroupingTest { + + @Test + fun `common and advanced together cover every mode exactly once`() { + val combined = NetworkMode.common + NetworkMode.advanced + + assertEquals(NetworkMode.entries.size, combined.size) + assertEquals(NetworkMode.entries.toSet(), combined.toSet()) + } + + @Test + fun `common offers one mode per radio generation`() { + assertEquals( + listOf( + NetworkMode.GSM_ONLY, + NetworkMode.WCDMA_ONLY, + NetworkMode.LTE_ONLY, + NetworkMode.NR_ONLY, + NetworkMode.NR_LTE + ), + NetworkMode.common + ) + } + + @Test + fun `advanced excludes the common modes`() { + assertTrue(NetworkMode.advanced.none { it in NetworkMode.common }) + } +} From fe33d76e30fa4f29e62784415a12158a9593f920 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Mon, 21 Sep 2026 23:01:46 +0530 Subject: [PATCH 20/22] feat: show which SIM the toggle and config screen act on --- .../domain/usecase/NetworkUseCases.kt | 14 ++++ .../presentation/ui/activity/MainActivity.kt | 1 + .../ui/activity/NetworkModeConfigActivity.kt | 10 +++ .../ui/composable/NetworkSwitchComponents.kt | 10 +++ .../presentation/viewmodel/MainViewModel.kt | 16 ++++ .../viewmodel/NetworkModeConfigViewModel.kt | 23 +++++- app/src/main/res/values/strings.xml | 1 + .../domain/usecase/GetSimNameUseCaseTest.kt | 53 ++++++++++++ .../NetworkModeConfigViewModelTest.kt | 82 +++++++++++++++++++ 9 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/usecase/GetSimNameUseCaseTest.kt create mode 100644 app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModelTest.kt 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 6c86f91..11e7433 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 @@ -102,6 +102,20 @@ class GetAvailableSimsUseCase @Inject constructor( } } +/** + * Name of the SIM with the given subscription ID. Null with fewer than two SIMs, where + * there is nothing to tell apart, and when the SIM set cannot be read. + */ +class GetSimNameUseCase @Inject constructor( + private val getAvailableSims: GetAvailableSimsUseCase +) { + suspend operator fun invoke(subId: Int): String? { + val sims = (getAvailableSims() as? SimQueryResult.Loaded)?.sims.orEmpty() + if (sims.size < 2) return null + return sims.find { it.subscriptionId == subId }?.displayName + } +} + /** * Use case for getting the selected subscription ID */ diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt index 1c08c2c..56c7376 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt @@ -103,6 +103,7 @@ private fun MainScreen( if (compatibilityState is CompatibilityState.Compatible) { NetworkToggleCard( currentMode = viewModel.currentNetworkMode, + activeSimName = viewModel.activeSimName, toggleButtonText = viewModel.getToggleButtonText(), isLoading = viewModel.isLoading, onToggleClick = { viewModel.toggleNetworkMode() } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt index 859b90e..f64bc12 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt @@ -54,6 +54,7 @@ private fun NetworkModeConfigScreen( val currentConfig by viewModel.currentConfig.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val configSaved by viewModel.configSaved.collectAsState() + val simName by viewModel.simName.collectAsState() // Show toast when configuration is saved LaunchedEffect(configSaved) { @@ -111,6 +112,15 @@ private fun NetworkModeConfigScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + simName?.let { name -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.configuring_sim, name), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } } } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt index 36c23b3..69ded0a 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt @@ -123,6 +123,7 @@ fun CompatibilityCard( @Composable fun NetworkToggleCard( currentMode: NetworkMode?, + activeSimName: String?, toggleButtonText: String, isLoading: Boolean, onToggleClick: () -> Unit, @@ -133,6 +134,15 @@ fun NetworkToggleCard( modifier = modifier ) { Spacer(modifier = Modifier.height(16.dp)) + + if (activeSimName != null) { + Text( + text = "SIM: $activeSimName", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + } Text( text = if (currentMode != null) "Current: ${currentMode.displayName}" else "Network mode unavailable", 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 9c07d45..b51b8ee 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 @@ -11,6 +11,7 @@ import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.SimConfigKey import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.CheckCompatibilityUseCase +import com.supernova.networkswitch.domain.usecase.GetSimNameUseCase import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase @@ -34,6 +35,7 @@ class MainViewModel @Inject constructor( private val getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase, private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, + private val getSimNameUseCase: GetSimNameUseCase, private val preferencesRepository: PreferencesRepository ) : ViewModel() { @@ -52,6 +54,10 @@ class MainViewModel @Inject constructor( var compatibilityState by mutableStateOf(CompatibilityState.Pending) private set + /** The SIM the toggle acts on; null when there is only one SIM to act on. */ + var activeSimName by mutableStateOf(null) + private set + private var refreshJob: Job? = null @@ -77,6 +83,15 @@ class MainViewModel @Inject constructor( .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } .collectLatest { newConfig -> toggleModeConfig = newConfig } } + + viewModelScope.launch { + preferencesRepository.observeSelectedSubscriptionId() + .collectLatest { loadActiveSim() } + } + } + + private suspend fun loadActiveSim() { + activeSimName = getSimNameUseCase(getEffectiveSubscriptionIdUseCase()) } /** @@ -114,6 +129,7 @@ class MainViewModel @Inject constructor( /** Called on resume; the activity has no other signal that state may be stale. */ fun refreshAllData() { refresh() + viewModelScope.launch { loadActiveSim() } } fun toggleNetworkMode() { diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt index 29c818b..a3b9650 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.GetSimNameUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.usecase.UpdateToggleModeConfigUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -21,7 +22,8 @@ import javax.inject.Inject class NetworkModeConfigViewModel @Inject constructor( private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, private val updateToggleModeConfigUseCase: UpdateToggleModeConfigUseCase, - private val getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase + private val getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase, + private val getSimName: GetSimNameUseCase ) : ViewModel() { private val _currentConfig = MutableStateFlow( @@ -34,6 +36,16 @@ class NetworkModeConfigViewModel @Inject constructor( private val _configSaved = MutableStateFlow(false) val configSaved: StateFlow = _configSaved.asStateFlow() + + /** The SIM being configured; null when there is only one SIM to configure. */ + private val _simName = MutableStateFlow(null) + val simName: StateFlow = _simName.asStateFlow() + + /** + * Resolved once, so a save lands on the SIM that was loaded even if the selected SIM + * changes while the screen is open. Null until loading finishes. + */ + private var subId: Int? = null init { loadCurrentConfiguration() @@ -42,8 +54,10 @@ class NetworkModeConfigViewModel @Inject constructor( private fun loadCurrentConfiguration() { viewModelScope.launch { try { - val config = getToggleModeConfigUseCase(getEffectiveSubscriptionId()) - _currentConfig.value = config + val resolved = getEffectiveSubscriptionId() + subId = resolved + _simName.value = getSimName(resolved) + _currentConfig.value = getToggleModeConfigUseCase(resolved) } catch (e: Exception) { // Use default configuration if loading fails _currentConfig.value = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) @@ -63,11 +77,12 @@ class NetworkModeConfigViewModel @Inject constructor( if (_currentConfig.value.modeA == _currentConfig.value.modeB) { return // Don't save if both modes are the same } + val target = subId ?: return _isLoading.value = true viewModelScope.launch { try { - updateToggleModeConfigUseCase(getEffectiveSubscriptionId(), _currentConfig.value) + updateToggleModeConfigUseCase(target, _currentConfig.value) _configSaved.value = true } catch (e: Exception) { // Handle error (could show toast or snackbar) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d94aa1b..65f62fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Mode B Save Configuration Configuration saved successfully + Configuring: %1$s SIM Select diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetSimNameUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetSimNameUseCaseTest.kt new file mode 100644 index 0000000..636d340 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetSimNameUseCaseTest.kt @@ -0,0 +1,53 @@ +package com.supernova.networkswitch.domain.usecase + +import com.supernova.networkswitch.domain.model.SimInfo +import com.supernova.networkswitch.domain.model.SimQueryResult +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 + +class GetSimNameUseCaseTest { + + private lateinit var getAvailableSims: GetAvailableSimsUseCase + private lateinit var useCase: GetSimNameUseCase + + private val jio = SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "Jio (Slot 1)") + private val airtel = SimInfo(subscriptionId = 3, simSlotIndex = 1, displayName = "Airtel (Slot 2)") + + @Before + fun setUp() { + getAvailableSims = mockk() + useCase = GetSimNameUseCase(getAvailableSims) + } + + @Test + fun `should name the SIM when there are several to tell apart`() = runTest { + coEvery { getAvailableSims() } returns SimQueryResult.Loaded(listOf(jio, airtel)) + + assertEquals("Airtel (Slot 2)", useCase(3)) + } + + @Test + fun `should not name the only SIM`() = runTest { + coEvery { getAvailableSims() } returns SimQueryResult.Loaded(listOf(jio)) + + assertNull(useCase(1)) + } + + @Test + fun `should not guess a name when the SIM set is unreadable`() = runTest { + coEvery { getAvailableSims() } returns SimQueryResult.PermissionDenied + + assertNull(useCase(1)) + } + + @Test + fun `should not name a subscription that is not present`() = runTest { + coEvery { getAvailableSims() } returns SimQueryResult.Loaded(listOf(jio, airtel)) + + assertNull(useCase(99)) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModelTest.kt b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModelTest.kt new file mode 100644 index 0000000..774e1a0 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/NetworkModeConfigViewModelTest.kt @@ -0,0 +1,82 @@ +package com.supernova.networkswitch.presentation.viewmodel + +import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.GetSimNameUseCase +import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase +import com.supernova.networkswitch.domain.usecase.UpdateToggleModeConfigUseCase +import com.supernova.networkswitch.util.CoroutineTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkModeConfigViewModelTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var getToggleModeConfig: GetToggleModeConfigUseCase + private lateinit var updateToggleModeConfig: UpdateToggleModeConfigUseCase + private lateinit var getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase + private lateinit var getSimName: GetSimNameUseCase + + private val loadedSubId = 1 + private val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) + + @Before + fun setUp() { + getToggleModeConfig = mockk() + updateToggleModeConfig = mockk(relaxed = true) + getEffectiveSubscriptionId = mockk() + getSimName = mockk() + + coEvery { getEffectiveSubscriptionId() } returns loadedSubId + coEvery { getToggleModeConfig(loadedSubId) } returns config + coEvery { getSimName(loadedSubId) } returns "Jio (Slot 1)" + } + + private fun createViewModel() = NetworkModeConfigViewModel( + getToggleModeConfig, + updateToggleModeConfig, + getEffectiveSubscriptionId, + getSimName + ) + + @Test + fun `should label the SIM being configured`() = runTest { + val viewModel = createViewModel() + + assertEquals("Jio (Slot 1)", viewModel.simName.value) + } + + /** The selected SIM can change from the Quick Settings tile while this screen is open. */ + @Test + fun `should save to the SIM that was loaded even if the selection changes`() = runTest { + val viewModel = createViewModel() + coEvery { getEffectiveSubscriptionId() } returns 3 + + viewModel.updateModeA(NetworkMode.WCDMA_ONLY) + viewModel.saveConfiguration() + + coVerify { updateToggleModeConfig(loadedSubId, any()) } + coVerify(exactly = 0) { updateToggleModeConfig(3, any()) } + } + + @Test + fun `should not save identical modes`() = runTest { + val viewModel = createViewModel() + + viewModel.updateModeB(NetworkMode.LTE_ONLY) + viewModel.saveConfiguration() + + coVerify(exactly = 0) { updateToggleModeConfig(any(), any()) } + } +} From a993d8d86f06efd86bf239edf53d2e97ea9fef00 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 27 Sep 2026 16:54:18 +0530 Subject: [PATCH 21/22] fix: keep the toggle working when the mode reads back differently --- .../repository/PreferencesRepositoryImpl.kt | 9 ++ .../data/source/PreferencesDataSource.kt | 12 +++ .../domain/model/NetworkSwitchModels.kt | 28 ++++-- .../domain/repository/Repositories.kt | 9 +- .../domain/usecase/NetworkUseCases.kt | 47 ++++++---- .../presentation/ui/activity/MainActivity.kt | 1 + .../ui/composable/NetworkSwitchComponents.kt | 3 +- .../presentation/viewmodel/MainViewModel.kt | 51 ++++++---- .../service/NetworkTileService.kt | 37 +++----- .../source/ToggleModeConfigStorageTest.kt | 27 +++--- .../domain/model/ToggleModeConfigTest.kt | 77 +++++++++++----- .../usecase/GetToggleStateUseCaseTest.kt | 49 ++++++++++ .../usecase/ToggleNetworkModeUseCaseTest.kt | 92 +++++++++++++------ 13 files changed, 307 insertions(+), 135 deletions(-) create mode 100644 app/src/test/java/com/supernova/networkswitch/domain/usecase/GetToggleStateUseCaseTest.kt diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt index 558db73..b7b0844 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.supernova.networkswitch.data.repository import com.supernova.networkswitch.data.source.PreferencesDataSource +import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -40,6 +41,14 @@ class PreferencesRepositoryImpl @Inject constructor( return preferencesDataSource.observeToggleModeConfig(simKey) } + override suspend fun getLastAppliedMode(simKey: String): NetworkMode? { + return preferencesDataSource.getLastAppliedMode(simKey) + } + + override suspend fun setLastAppliedMode(simKey: String, mode: NetworkMode) { + preferencesDataSource.setLastAppliedMode(simKey, mode) + } + override suspend fun getSelectedSubscriptionId(): Int { return preferencesDataSource.getSelectedSubscriptionId() } 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 703f212..16de859 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 @@ -26,6 +26,7 @@ class PreferencesDataSource @Inject constructor( private fun modeAKey(simKey: String) = intPreferencesKey("toggle_mode_a_$simKey") private fun modeBKey(simKey: String) = intPreferencesKey("toggle_mode_b_$simKey") + private fun lastAppliedModeKey(simKey: String) = intPreferencesKey("last_applied_mode_$simKey") private val SELECTED_SUBSCRIPTION_ID_KEY = intPreferencesKey("selected_subscription_id") private const val DEFAULT_CONTROL_METHOD = "SHIZUKU" @@ -87,6 +88,17 @@ class PreferencesDataSource @Inject constructor( ) } + suspend fun getLastAppliedMode(simKey: String): NetworkMode? { + val value = dataStore.data.first()[lastAppliedModeKey(simKey)] ?: return null + return NetworkMode.fromValue(value) + } + + suspend fun setLastAppliedMode(simKey: String, mode: NetworkMode) { + dataStore.edit { preferences -> + preferences[lastAppliedModeKey(simKey)] = mode.value + } + } + /** * Get the selected subscription ID for the SIM card 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 27ecc52..235127f 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 @@ -51,7 +51,14 @@ enum class NetworkMode(val displayName: String, val value: Int) { NR_LTE_TDSCDMA_WCDMA("3G/4G/5G (NR/LTE/TD-SCDMA/WCDMA)", 31), NR_LTE_TDSCDMA_GSM_WCDMA("2G/3G/4G/5G (NR/LTE/TD-SCDMA/GSM/WCDMA)", 32), NR_LTE_TDSCDMA_CDMA_EVDO_GSM_WCDMA("Global 5G + TD-SCDMA (All Networks)", 33); - + + /** + * GSM_UMTS and WCDMA_PREF allow the same radios, so on Android 11+ they store the + * same bitmask and GSM_UMTS reads back as WCDMA_PREF. + */ + infix fun sameRadiosAs(other: NetworkMode?): Boolean = + this == other || setOf(this, other) == setOf(WCDMA_PREF, GSM_UMTS) + companion object { /** * Get NetworkMode by RIL constant value @@ -85,15 +92,22 @@ data class ToggleModeConfig( val modeB: NetworkMode ) { /** - * The mode a toggle moves to, given the mode the radio is on. - * - * [actual] is null when the mode is unreadable, and is outside this pair when the - * SIM was configured elsewhere. Both target [modeA]. + * Matches modes by [NetworkMode.sameRadiosAs]. When [actual] is unreadable, alternates + * from [lastApplied]; a mode outside the pair restarts at [modeA]. */ - fun nextAfter(actual: NetworkMode?): NetworkMode = - if (actual == modeA) modeB else modeA + fun resolve(actual: NetworkMode?, lastApplied: NetworkMode?): ToggleState = when { + modeA sameRadiosAs actual -> ToggleState(modeA, modeB) + modeB sameRadiosAs actual -> ToggleState(modeB, modeA) + actual == null && lastApplied == modeA -> ToggleState(null, modeB) + else -> ToggleState(actual, modeA) + } } +data class ToggleState( + val current: NetworkMode?, + val next: NetworkMode +) + sealed class CompatibilityState { object Pending : CompatibilityState() object Compatible : CompatibilityState() 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 7beb8e9..f36b156 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 @@ -67,7 +67,14 @@ interface PreferencesRepository { * Observe toggle mode configuration changes */ fun observeToggleModeConfig(simKey: String): Flow - + + /** + * The mode the app last set on this SIM; the fallback when the radio's mode cannot be read. + */ + suspend fun getLastAppliedMode(simKey: String): NetworkMode? + + suspend fun setLastAppliedMode(simKey: String, mode: NetworkMode) + /** * Get the selected subscription ID for the SIM card * Returns [SubscriptionSelection.AUTO] if no specific SIM is selected 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 11e7433..c85de9e 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 @@ -3,15 +3,15 @@ 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.SimConfigKey import com.supernova.networkswitch.domain.model.SimQueryResult import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.ToggleState import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository import com.supernova.networkswitch.domain.repository.SimRepository +import kotlinx.coroutines.CancellationException import javax.inject.Inject class CheckCompatibilityUseCase @Inject constructor( @@ -29,34 +29,41 @@ class ToggleNetworkModeUseCase @Inject constructor( private val networkControlRepository: NetworkControlRepository, private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(subId: Int): Result { + suspend operator fun invoke(subId: Int): Result { return try { - val toggleConfig = preferencesRepository.getToggleModeConfig(SimConfigKey.forSubscription(subId)) + val simKey = SimConfigKey.forSubscription(subId) + val config = preferencesRepository.getToggleModeConfig(simKey) + val actual = networkControlRepository.getCurrentNetworkMode(subId) + val target = config.resolve(actual, preferencesRepository.getLastAppliedMode(simKey)).next - // Read the mode this SIM is actually on: the target depends on it, and it - // may have been changed by another SIM's toggle or outside the app. - val actualMode = networkControlRepository.getCurrentNetworkMode(subId) - val targetMode = toggleConfig.nextAfter(actualMode) + networkControlRepository.setNetworkMode(subId, target).getOrElse { return Result.failure(it) } + try { + preferencesRepository.setLastAppliedMode(simKey, target) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The mode is already applied; the record is only a fallback for unreadable modes. + } - networkControlRepository.setNetworkMode(subId, targetMode).map { targetMode } + Result.success(config.resolve(target, null)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } } } -/** - * Use case for getting current network mode - */ -class GetCurrentNetworkModeUseCase @Inject constructor( - private val networkControlRepository: NetworkControlRepository +class GetToggleStateUseCase @Inject constructor( + private val networkControlRepository: NetworkControlRepository, + private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(subId: Int): Result { - return try { - Result.success(networkControlRepository.getCurrentNetworkMode(subId)) - } catch (e: Exception) { - Result.failure(e) - } + suspend operator fun invoke(subId: Int): ToggleState { + val simKey = SimConfigKey.forSubscription(subId) + return preferencesRepository.getToggleModeConfig(simKey).resolve( + networkControlRepository.getCurrentNetworkMode(subId), + preferencesRepository.getLastAppliedMode(simKey) + ) } } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt index 56c7376..3f2e428 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt @@ -105,6 +105,7 @@ private fun MainScreen( currentMode = viewModel.currentNetworkMode, activeSimName = viewModel.activeSimName, toggleButtonText = viewModel.getToggleButtonText(), + canToggle = viewModel.canToggle, isLoading = viewModel.isLoading, onToggleClick = { viewModel.toggleNetworkMode() } ) diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt index 69ded0a..5823850 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt @@ -125,6 +125,7 @@ fun NetworkToggleCard( currentMode: NetworkMode?, activeSimName: String?, toggleButtonText: String, + canToggle: Boolean, isLoading: Boolean, onToggleClick: () -> Unit, modifier: Modifier = Modifier @@ -167,7 +168,7 @@ fun NetworkToggleCard( Button( onClick = onToggleClick, - enabled = !isLoading && currentMode != null, + enabled = !isLoading && canToggle, modifier = Modifier.fillMaxWidth() ) { if (isLoading) { 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 b51b8ee..8618d6c 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 @@ -9,19 +9,21 @@ 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.SimConfigKey -import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.ToggleState import com.supernova.networkswitch.domain.usecase.CheckCompatibilityUseCase import com.supernova.networkswitch.domain.usecase.GetSimNameUseCase -import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.GetToggleStateUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.UpdateControlMethodUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest import javax.inject.Inject @@ -31,7 +33,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel @HiltViewModel class MainViewModel @Inject constructor( private val checkCompatibilityUseCase: CheckCompatibilityUseCase, - private val getCurrentNetworkModeUseCase: GetCurrentNetworkModeUseCase, + private val getToggleStateUseCase: GetToggleStateUseCase, private val getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase, private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, @@ -45,8 +47,10 @@ class MainViewModel @Inject constructor( var currentNetworkMode by mutableStateOf(null) private set - var toggleModeConfig by mutableStateOf(ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY)) - private set + private var nextMode by mutableStateOf(null) + + val canToggle: Boolean + get() = nextMode != null var isLoading by mutableStateOf(false) private set @@ -81,7 +85,8 @@ class MainViewModel @Inject constructor( .mapLatest { SimConfigKey.forSubscription(getEffectiveSubscriptionIdUseCase()) } .distinctUntilChanged() .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } - .collectLatest { newConfig -> toggleModeConfig = newConfig } + .drop(1) // init already refreshes + .collect { refresh() } } viewModelScope.launch { @@ -95,25 +100,32 @@ class MainViewModel @Inject constructor( } /** - * Sole writer of [compatibilityState] and [currentNetworkMode]. - * - * The mode read is only meaningful once compatibility is confirmed, so both happen - * in one coroutine; routing every caller through here keeps the two from disagreeing. - * An in-flight refresh is cancelled, so the newest request wins. + * Sole writer of [compatibilityState]. The mode read is only meaningful once + * compatibility is confirmed, so both happen in one coroutine and are published + * together. An in-flight refresh is cancelled, so the newest request wins. */ private fun refresh() { refreshJob?.cancel() refreshJob = viewModelScope.launch { compatibilityState = CompatibilityState.Pending val state = checkCompatibilityUseCase() + val toggle = if (state is CompatibilityState.Compatible) readToggleState() else null compatibilityState = state - currentNetworkMode = if (state is CompatibilityState.Compatible) readCurrentMode() else null + showToggleState(toggle) } } - private suspend fun readCurrentMode(): NetworkMode? { - val subId = getEffectiveSubscriptionIdUseCase() - return getCurrentNetworkModeUseCase(subId).getOrNull() + private suspend fun readToggleState(): ToggleState? = try { + getToggleStateUseCase(getEffectiveSubscriptionIdUseCase()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + private fun showToggleState(state: ToggleState?) { + currentNetworkMode = state?.current + nextMode = state?.next } fun switchToMethod(method: ControlMethod) { @@ -140,8 +152,9 @@ class MainViewModel @Inject constructor( val subId = getEffectiveSubscriptionIdUseCase() toggleNetworkModeUseCase(subId) - .onSuccess { newMode -> - currentNetworkMode = newMode + .onSuccess { + // A refresh still running may have read the mode from before the set. + if (refreshJob?.isActive == true) refresh() else showToggleState(it) } .onFailure { refresh() @@ -152,8 +165,8 @@ class MainViewModel @Inject constructor( } fun getToggleButtonText(): String { - val nextMode = toggleModeConfig.nextAfter(currentNetworkMode) - return "Switch to ${nextMode.displayName}" + val next = nextMode ?: return "Switch" + return "Switch to ${next.displayName}" } } 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 c9dd6fb..1402cea 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -4,12 +4,11 @@ import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import com.supernova.networkswitch.R -import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.SimConfigKey -import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.ToggleState import com.supernova.networkswitch.domain.repository.PreferencesRepository -import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.GetEffectiveSubscriptionIdUseCase +import com.supernova.networkswitch.domain.usecase.GetToggleStateUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CancellationException @@ -32,7 +31,7 @@ import javax.inject.Inject class NetworkTileService : TileService() { @Inject - lateinit var getCurrentNetworkModeUseCase: GetCurrentNetworkModeUseCase + lateinit var getToggleStateUseCase: GetToggleStateUseCase @Inject lateinit var getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase @@ -51,8 +50,7 @@ class NetworkTileService : TileService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var listeningJob: Job? = null - private var currentNetworkMode: NetworkMode? = null - private var toggleConfig: ToggleModeConfig? = null + private var toggleState: ToggleState? = null override fun onStartListening() { super.onStartListening() @@ -63,10 +61,7 @@ class NetworkTileService : TileService() { .mapLatest { SimConfigKey.forSubscription(getEffectiveSubscriptionIdUseCase()) } .distinctUntilChanged() .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } - .collect { newConfig -> - toggleConfig = newConfig - refreshNetworkState() - } + .collect { refreshNetworkState() } } catch (_: CancellationException) { // Expected when the tile stops listening } catch (e: Exception) { @@ -105,7 +100,7 @@ class NetworkTileService : TileService() { withTimeout(TOGGLE_TIMEOUT_MS) { val subId = getEffectiveSubscriptionIdUseCase() toggleNetworkModeUseCase(subId) - .onSuccess { newMode -> currentNetworkMode = newMode } + .onSuccess { toggleState = it } .onFailure { e -> Log.e(TAG, "Tile: Toggle failed", e) } } } catch (e: Exception) { @@ -118,12 +113,9 @@ class NetworkTileService : TileService() { private suspend fun refreshNetworkState() { try { - // Use the user's selected subscription ID (or default if "Auto" selected) - val subId = getEffectiveSubscriptionIdUseCase() - - getCurrentNetworkModeUseCase(subId) - .onSuccess { networkMode -> currentNetworkMode = networkMode } - .onFailure { e -> Log.e(TAG, "Tile: Failed to read network mode", e) } + toggleState = getToggleStateUseCase(getEffectiveSubscriptionIdUseCase()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Tile: Error refreshing network state", e) } @@ -135,15 +127,12 @@ class NetworkTileService : TileService() { try { // Null outside the listening window; the next onStartListening repaints. val tile = qsTile ?: return - val config = toggleConfig + val state = toggleState - if (config != null) { + if (state != null) { tile.state = Tile.STATE_ACTIVE - tile.label = currentNetworkMode?.displayName ?: getString(R.string.tile_not_loaded) - tile.subtitle = getString( - R.string.tile_next_mode, - config.nextAfter(currentNetworkMode).displayName, - ) + tile.label = state.current?.displayName ?: getString(R.string.tile_not_loaded) + tile.subtitle = getString(R.string.tile_next_mode, state.next.displayName) } else { tile.state = Tile.STATE_INACTIVE tile.label = getString(R.string.network_switch) diff --git a/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt b/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt index 9ac5219..bdb5bfd 100644 --- a/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt @@ -22,7 +22,6 @@ class ToggleModeConfigStorageTest { private lateinit var dataStore: InMemoryPreferencesDataStore private lateinit var source: PreferencesDataSource - private val simA = "sim-a" private val simB = "sim-b" @@ -34,7 +33,6 @@ class ToggleModeConfigStorageTest { source = PreferencesDataSource(dataStore) } - @Test fun `should default when nothing is stored`() = runTest { assertEquals(defaults, source.getToggleModeConfig(simA)) @@ -55,15 +53,6 @@ class ToggleModeConfigStorageTest { ) } - - - - - - - - - @Test fun `should emit again when the same SIM's configuration changes`() = runTest { val seen = mutableListOf() @@ -78,9 +67,6 @@ class ToggleModeConfigStorageTest { assertEquals(ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY), seen.last()) } - - - @Test fun `should observe writes for the same SIM`() = runTest { source.setToggleModeConfig(simA, ToggleModeConfig(NetworkMode.GSM_ONLY, NetworkMode.LTE_ONLY)) @@ -100,4 +86,17 @@ class ToggleModeConfigStorageTest { assertEquals(defaults, source.getToggleModeConfig(simA)) } + + @Test + fun `should have no last applied mode until one is recorded`() = runTest { + assertNull(source.getLastAppliedMode(simA)) + } + + @Test + fun `should keep each SIM's last applied mode separate`() = runTest { + source.setLastAppliedMode(simA, NetworkMode.GSM_UMTS) + + assertEquals(NetworkMode.GSM_UMTS, source.getLastAppliedMode(simA)) + assertNull(source.getLastAppliedMode(simB)) + } } diff --git a/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt index abd8a69..c6939d8 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/model/ToggleModeConfigTest.kt @@ -3,56 +3,91 @@ package com.supernova.networkswitch.domain.model import org.junit.Assert.* import org.junit.Test -/** - * Unit tests for ToggleModeConfig - */ class ToggleModeConfigTest { private val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) @Test fun `should target mode B when the radio is on mode A`() { - assertEquals(NetworkMode.NR_ONLY, config.nextAfter(NetworkMode.LTE_ONLY)) + assertEquals( + ToggleState(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY), + config.resolve(NetworkMode.LTE_ONLY, null) + ) } @Test fun `should target mode A when the radio is on mode B`() { - assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_ONLY)) + assertEquals( + ToggleState(NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), + config.resolve(NetworkMode.NR_ONLY, null) + ) } @Test - fun `should target mode A when the radio mode is unknown`() { - assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(null)) + fun `should trust a readable mode over the last applied one`() { + assertEquals(NetworkMode.LTE_ONLY, config.resolve(NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY).next) } @Test - fun `should target mode A when the radio is on a mode outside the pair`() { - assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_LTE_GSM_WCDMA)) + fun `should restart at mode A when the radio is outside the pair`() { + assertEquals( + ToggleState(NetworkMode.NR_LTE_GSM_WCDMA, NetworkMode.LTE_ONLY), + config.resolve(NetworkMode.NR_LTE_GSM_WCDMA, NetworkMode.LTE_ONLY) + ) } - /** Two SIMs on different modes get different targets from one shared config. */ @Test - fun `should give each radio mode its own target from the same config`() { - assertEquals(NetworkMode.NR_ONLY, config.nextAfter(NetworkMode.LTE_ONLY)) - assertEquals(NetworkMode.LTE_ONLY, config.nextAfter(NetworkMode.NR_ONLY)) + fun `should leave mode A when it reads back as a mode with the same radios`() { + val umts = ToggleModeConfig(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY) + + assertEquals( + ToggleState(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY), + umts.resolve(NetworkMode.WCDMA_PREF, null) + ) } @Test - fun `should alternate when the radio follows the reported target`() { - var actual: NetworkMode? = NetworkMode.LTE_ONLY + fun `should report a mode B of GSM_UMTS that reads back as WCDMA_PREF`() { + val umts = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.GSM_UMTS) + + assertEquals( + ToggleState(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY), + umts.resolve(NetworkMode.WCDMA_PREF, null) + ) + } - actual = config.nextAfter(actual) - assertEquals(NetworkMode.NR_ONLY, actual) + @Test + fun `should target mode A when the mode is unreadable and nothing was applied`() { + assertEquals(ToggleState(null, NetworkMode.LTE_ONLY), config.resolve(null, null)) + } + + @Test + fun `should alternate when the mode can never be read`() { + val first = config.resolve(null, null).next + val second = config.resolve(null, first).next + val third = config.resolve(null, second).next - actual = config.nextAfter(actual) - assertEquals(NetworkMode.LTE_ONLY, actual) + assertEquals( + listOf(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), + listOf(first, second, third) + ) + } + + @Test + fun `should target mode A when the mode is unreadable and mode B was last applied`() { + assertEquals(ToggleState(null, NetworkMode.LTE_ONLY), config.resolve(null, NetworkMode.NR_ONLY)) + } + + @Test + fun `should ignore a last applied mode no longer in the pair`() { + assertEquals(NetworkMode.LTE_ONLY, config.resolve(null, NetworkMode.GSM_ONLY).next) } @Test fun `should handle a config whose two modes are identical`() { val same = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.LTE_ONLY) - assertEquals(NetworkMode.LTE_ONLY, same.nextAfter(NetworkMode.LTE_ONLY)) - assertEquals(NetworkMode.LTE_ONLY, same.nextAfter(null)) + assertEquals(NetworkMode.LTE_ONLY, same.resolve(NetworkMode.LTE_ONLY, null).next) + assertEquals(NetworkMode.LTE_ONLY, same.resolve(null, null).next) } } diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetToggleStateUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetToggleStateUseCaseTest.kt new file mode 100644 index 0000000..0aac69e --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetToggleStateUseCaseTest.kt @@ -0,0 +1,49 @@ +package com.supernova.networkswitch.domain.usecase + +import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.SimConfigKey +import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.ToggleState +import com.supernova.networkswitch.domain.repository.NetworkControlRepository +import com.supernova.networkswitch.domain.repository.PreferencesRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class GetToggleStateUseCaseTest { + + private lateinit var networkControlRepository: NetworkControlRepository + private lateinit var preferencesRepository: PreferencesRepository + private lateinit var useCase: GetToggleStateUseCase + + private val subId = 3 + private val simKey = SimConfigKey.forSubscription(subId) + + @Before + fun setUp() { + networkControlRepository = mockk() + preferencesRepository = mockk() + useCase = GetToggleStateUseCase(networkControlRepository, preferencesRepository) + coEvery { preferencesRepository.getToggleModeConfig(simKey) } returns + ToggleModeConfig(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY) + coEvery { preferencesRepository.getLastAppliedMode(simKey) } returns null + } + + @Test + fun `should report the configured mode when the radio reads back an equivalent one`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(subId) } returns NetworkMode.WCDMA_PREF + + assertEquals(ToggleState(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY), useCase(subId)) + } + + @Test + fun `should use this SIM's last applied mode when the mode is unreadable`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(subId) } returns null + coEvery { preferencesRepository.getLastAppliedMode(simKey) } returns NetworkMode.GSM_UMTS + + assertEquals(ToggleState(null, NetworkMode.LTE_ONLY), useCase(subId)) + } +} diff --git a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt index 8b11d46..6cf84ad 100644 --- a/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt @@ -1,20 +1,23 @@ package com.supernova.networkswitch.domain.usecase import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.SimConfigKey import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.ToggleState import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest +import java.io.IOException import org.junit.Assert.* import org.junit.Before import org.junit.Test -/** - * Unit tests for ToggleNetworkModeUseCase - */ class ToggleNetworkModeUseCaseTest { private lateinit var networkControlRepository: NetworkControlRepository @@ -22,6 +25,7 @@ class ToggleNetworkModeUseCaseTest { private lateinit var useCase: ToggleNetworkModeUseCase private val testSubId = 1 + private val simKey = SimConfigKey.forSubscription(testSubId) private val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY) @Before @@ -30,84 +34,116 @@ class ToggleNetworkModeUseCaseTest { preferencesRepository = mockk() useCase = ToggleNetworkModeUseCase(networkControlRepository, preferencesRepository) coEvery { preferencesRepository.getToggleModeConfig(any()) } returns config + coEvery { preferencesRepository.getLastAppliedMode(any()) } returns null + coEvery { preferencesRepository.setLastAppliedMode(any(), any()) } just runs + coEvery { networkControlRepository.setNetworkMode(any(), any()) } returns Result.success(Unit) } @Test fun `should switch to mode B when the SIM is on mode A`() = runTest { coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.success(Unit) val result = useCase(testSubId) - assertEquals(NetworkMode.NR_ONLY, result.getOrNull()) + assertEquals(ToggleState(NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), result.getOrNull()) coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } } @Test fun `should switch to mode A when the SIM is on mode B`() = runTest { coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.NR_ONLY - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) val result = useCase(testSubId) - assertEquals(NetworkMode.LTE_ONLY, result.getOrNull()) + assertEquals(ToggleState(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY), result.getOrNull()) coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } } - /** A SIM already on mode B must not be told to switch to mode B. */ @Test - fun `should target each SIM from its own mode`() = runTest { - val otherSubId = 2 + fun `should leave a mode A of GSM_UMTS that reads back as WCDMA_PREF`() = runTest { + coEvery { preferencesRepository.getToggleModeConfig(simKey) } returns + ToggleModeConfig(NetworkMode.GSM_UMTS, NetworkMode.LTE_ONLY) + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.WCDMA_PREF + + useCase(testSubId) + + coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } + } + + @Test + fun `should record the applied mode`() = runTest { coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY - coEvery { networkControlRepository.getCurrentNetworkMode(otherSubId) } returns NetworkMode.NR_ONLY - coEvery { networkControlRepository.setNetworkMode(any(), any()) } returns Result.success(Unit) - assertEquals(NetworkMode.NR_ONLY, useCase(testSubId).getOrNull()) - assertEquals(NetworkMode.LTE_ONLY, useCase(otherSubId).getOrNull()) + useCase(testSubId) + + coVerify { preferencesRepository.setLastAppliedMode(simKey, NetworkMode.NR_ONLY) } } @Test - fun `should target mode A when the current mode cannot be read`() = runTest { + fun `should alternate from the last applied mode when the mode is unreadable`() = runTest { coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns null - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) + coEvery { preferencesRepository.getLastAppliedMode(simKey) } returns NetworkMode.LTE_ONLY + + useCase(testSubId) - assertEquals(NetworkMode.LTE_ONLY, useCase(testSubId).getOrNull()) + coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } } @Test - fun `should target mode A when the SIM is on a mode outside the pair`() = runTest { - coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.NR_LTE_GSM_WCDMA - coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } returns Result.success(Unit) + fun `should target each SIM from its own mode`() = runTest { + val otherSubId = 2 + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { networkControlRepository.getCurrentNetworkMode(otherSubId) } returns NetworkMode.NR_ONLY + + useCase(testSubId) + useCase(otherSubId) - assertEquals(NetworkMode.LTE_ONLY, useCase(testSubId).getOrNull()) + coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } + coVerify { networkControlRepository.setNetworkMode(otherSubId, NetworkMode.LTE_ONLY) } } @Test - fun `should propagate a failure to apply the mode`() = runTest { + fun `should propagate a failure to apply the mode without recording it`() = runTest { val failure = RuntimeException("binder died") coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY coEvery { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } returns Result.failure(failure) val result = useCase(testSubId) - assertTrue(result.isFailure) assertEquals(failure, result.exceptionOrNull()) + coVerify(exactly = 0) { preferencesRepository.setLastAppliedMode(any(), any()) } } @Test - fun `should report a thrown error as a failure`() = runTest { - coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } throws IllegalStateException("no service") + fun `should still succeed when the applied mode cannot be recorded`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { preferencesRepository.setLastAppliedMode(any(), any()) } throws IOException("disk full") val result = useCase(testSubId) - assertTrue(result.isFailure) + assertEquals(ToggleState(NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), result.getOrNull()) + } + + @Test + fun `should not turn cancellation into a failure`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + coEvery { preferencesRepository.setLastAppliedMode(any(), any()) } throws CancellationException("timed out") + + val thrown = runCatching { useCase(testSubId) }.exceptionOrNull() + + assertTrue(thrown is CancellationException) + } + + @Test + fun `should report a thrown error as a failure`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } throws IllegalStateException("no service") + + assertTrue(useCase(testSubId).isFailure) } - /** The target is derived from the radio on each call, so a toggle persists nothing. */ @Test fun `should not write the toggle configuration`() = runTest { coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY - coEvery { networkControlRepository.setNetworkMode(any(), any()) } returns Result.success(Unit) useCase(testSubId) From 5b6813103e44e26a57a6a6bc1685f7b58b3fb352 Mon Sep 17 00:00:00 2001 From: Ameya Vijay Unchagaonkar Date: Sun, 27 Sep 2026 18:27:04 +0530 Subject: [PATCH 22/22] fix: bound the root bind and bind on the main thread --- .../NetworkControlRepositoryImpl.kt | 15 +- .../data/source/NetworkControlDataSource.kt | 2 - .../source/RootNetworkControlDataSource.kt | 119 ++++++------ .../source/ShizukuNetworkControlDataSource.kt | 8 +- .../domain/repository/Repositories.kt | 5 - .../RootNetworkControlDataSourceTest.kt | 175 ++++++++++++++++++ 6 files changed, 239 insertions(+), 85 deletions(-) create mode 100644 app/src/test/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSourceTest.kt diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index 606e38d..2d4cd94 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -6,8 +6,6 @@ import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.repository.NetworkControlRepository import com.supernova.networkswitch.domain.repository.PreferencesRepository @@ -28,12 +26,13 @@ class NetworkControlRepositoryImpl @Inject constructor( return getDataSource(method).checkCompatibility(subId) } - override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val method = preferencesRepository.getControlMethod() val dataSource = getDataSource(method) dataSource.getCurrentNetworkMode(subId) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { null } @@ -45,19 +44,13 @@ class NetworkControlRepositoryImpl @Inject constructor( val dataSource = getDataSource(method) dataSource.setNetworkMode(subId, mode) Result.success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } } - /** - * Reset connections for both data sources - useful when switching control methods - */ - override suspend fun resetConnections() { - rootDataSource.resetConnection() - shizukuDataSource.resetConnection() - } - private fun getDataSource(method: ControlMethod): NetworkControlDataSource { return when (method) { ControlMethod.ROOT -> rootDataSource diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt index d6fa53c..9a24a5b 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt @@ -7,6 +7,4 @@ interface NetworkControlDataSource { suspend fun checkCompatibility(subId: Int): CompatibilityState suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? suspend fun setNetworkMode(subId: Int, mode: NetworkMode) - fun isConnected(): Boolean - fun resetConnection() } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt index 0efae22..a668741 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt @@ -6,34 +6,46 @@ import android.content.Intent import android.content.ServiceConnection import android.os.IBinder import com.supernova.networkswitch.IRootController -import com.supernova.networkswitch.service.RootNetworkControllerService import com.supernova.networkswitch.domain.model.CompatibilityState -import kotlinx.coroutines.CancellationException +import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.service.RootNetworkControllerService import com.supernova.networkswitch.util.Utils import com.topjohnwu.superuser.ipc.RootService -import kotlinx.coroutines.suspendCancellableCoroutine +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import dagger.hilt.android.qualifiers.ApplicationContext @Singleton class RootNetworkControlDataSource @Inject constructor( @ApplicationContext private val context: Context ) : NetworkControlDataSource { - + + @Volatile private var networkController: IRootController? = null - private var isServiceConnected = false + /** Only accessed on the main thread. */ + private var pendingBind: CompletableDeferred? = null + private val bindLock = Mutex() + + private companion object { + /** RootService.bind never calls back if the root process cannot start. */ + private const val BIND_TIMEOUT_MS = 5_000L + } override suspend fun checkCompatibility(subId: Int): CompatibilityState { - if (!Utils.isRootGranted()) { - return CompatibilityState.PermissionDenied(com.supernova.networkswitch.domain.model.ControlMethod.ROOT) + // Blocks until the su prompt is answered. + val rootGranted = withContext(Dispatchers.IO) { Utils.isRootGranted() } + if (!rootGranted) { + return CompatibilityState.PermissionDenied(ControlMethod.ROOT) } - // Root access alone does not mean the root service is bound, and reads go - // through that binding. + // Root access alone does not mean the root service can bind. return if (getNetworkController() != null) { CompatibilityState.Compatible } else { @@ -42,9 +54,9 @@ class RootNetworkControlDataSource @Inject constructor( } override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { + val controller = getNetworkController() ?: return null return try { - val controller = getNetworkController() - val modeValue = controller?.getCurrentNetworkMode(subId) ?: -1 + val modeValue = controller.getCurrentNetworkMode(subId) if (modeValue == -1) null else NetworkMode.fromValue(modeValue) } catch (e: Exception) { null @@ -53,63 +65,46 @@ class RootNetworkControlDataSource @Inject constructor( override suspend fun setNetworkMode(subId: Int, mode: NetworkMode) { val controller = getNetworkController() - controller?.setNetworkMode(subId, mode.value) + ?: throw IllegalStateException("Root service unavailable") + controller.setNetworkMode(subId, mode.value) } - - override fun isConnected(): Boolean = isServiceConnected - - override fun resetConnection() { + private suspend fun getNetworkController(): IRootController? = bindLock.withLock { + networkController?.takeIf { it.asBinder().pingBinder() }?.let { return it } networkController = null - isServiceConnected = false - } - private suspend fun getNetworkController(): IRootController? { - if (networkController != null && isServiceConnected) { - return networkController + // libsu only binds on the main thread. + withContext(Dispatchers.Main) { + // libsu queues binds while the root process starts and runs them once it reports in. + // It never relaunches a process that died; the next bind does, if it had reported in. + val bind = pendingBind?.takeUnless { it.isCompleted } ?: startBind() + val controller = withTimeoutOrNull(BIND_TIMEOUT_MS) { bind.await() } + if (controller == null) pendingBind = null + controller } + } - return try { - suspendCancellableCoroutine { continuation -> - var isResumed = false - - val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - if (!isResumed) { - isResumed = true - networkController = IRootController.Stub.asInterface(service) - isServiceConnected = true - continuation.resume(networkController) - } - } + private fun startBind(): CompletableDeferred { + val bind = CompletableDeferred() + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // Runs even after the waiting call timed out, so a late connection is reused. + val controller = IRootController.Stub.asInterface(service) + networkController = controller + bind.complete(controller) + } - override fun onServiceDisconnected(name: ComponentName?) { - networkController = null - isServiceConnected = false - } - } - - continuation.invokeOnCancellation { - isServiceConnected = false - networkController = null - } - - try { - RootService.bind( - Intent(context, RootNetworkControllerService::class.java), - serviceConnection - ) - } catch (e: Exception) { - if (!isResumed) { - isResumed = true - continuation.resumeWithException(e) - } - } + override fun onServiceDisconnected(name: ComponentName?) { + networkController = null } + } + pendingBind = bind + + try { + RootService.bind(Intent(context, RootNetworkControllerService::class.java), connection) } catch (e: Exception) { - isServiceConnected = false - networkController = null - throw e + bind.complete(null) } + return bind } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt index 586c004..2d9a9f2 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt @@ -10,7 +10,6 @@ import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.service.ShizukuControllerService import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -29,7 +28,6 @@ class ShizukuNetworkControlDataSource @Inject constructor( @Volatile private var userService: IShizukuController? = null private val _isConnected = MutableStateFlow(false) - val isConnected = _isConnected.asStateFlow() private companion object { private const val SHIZUKU_PERMISSION_REQUEST_ID = 8 @@ -65,6 +63,8 @@ class ShizukuNetworkControlDataSource @Inject constructor( } CompatibilityState.Compatible + } catch (e: CancellationException) { + throw e } catch (e: Exception) { CompatibilityState.Incompatible("Shizuku not available: ${e.message}") } @@ -94,9 +94,7 @@ class ShizukuNetworkControlDataSource @Inject constructor( } - override fun isConnected(): Boolean = _isConnected.value - - override fun resetConnection() { + private fun resetConnection() { userService = null _isConnected.value = false } 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 f36b156..bb8137c 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 @@ -27,11 +27,6 @@ interface NetworkControlRepository { * Set network mode */ suspend fun setNetworkMode(subId: Int, mode: NetworkMode): Result - - /** - * Reset connections - useful when switching control methods - */ - suspend fun resetConnections() } /** diff --git a/app/src/test/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSourceTest.kt b/app/src/test/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSourceTest.kt new file mode 100644 index 0000000..3ab2db5 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSourceTest.kt @@ -0,0 +1,175 @@ +package com.supernova.networkswitch.data.source + +import android.content.ServiceConnection +import android.os.IBinder +import com.supernova.networkswitch.IRootController +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.util.Utils +import com.topjohnwu.superuser.ipc.RootService +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class RootNetworkControlDataSourceTest { + + private val dispatcher = StandardTestDispatcher() + private val connection = slot() + private lateinit var source: RootNetworkControlDataSource + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + mockkObject(Utils) + every { Utils.isRootGranted() } returns true + mockkStatic(RootService::class) + source = RootNetworkControlDataSource(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + private fun test(body: suspend TestScope.() -> Unit) = runTest(dispatcher) { body() } + + private fun controller(alive: Boolean = true): IRootController { + val binder = mockk { every { pingBinder() } returns alive } + return mockk(relaxed = true) { every { asBinder() } returns binder } + } + + private fun serviceFor(controller: IRootController): IBinder = + mockk { every { queryLocalInterface(any()) } returns controller } + + private fun bindNeverCallsBack() { + every { RootService.bind(any(), capture(connection)) } just runs + } + + private fun bindConnects(vararg controllers: IRootController) { + val queue = ArrayDeque(controllers.toList()) + every { RootService.bind(any(), capture(connection)) } answers { + connection.captured.onServiceConnected(null, serviceFor(queue.removeFirst())) + } + } + + @Test + fun `should report permission denied without binding when root is not granted`() = test { + every { Utils.isRootGranted() } returns false + + assertEquals(CompatibilityState.PermissionDenied(ControlMethod.ROOT), source.checkCompatibility(1)) + verify(exactly = 0) { RootService.bind(any(), any()) } + } + + @Test + fun `should report incompatible when the bind never calls back`() = test { + bindNeverCallsBack() + + assertTrue(source.checkCompatibility(1) is CompatibilityState.Incompatible) + } + + @Test + fun `should fail a set when the service cannot be bound`() = test { + bindNeverCallsBack() + + val thrown = runCatching { source.setNetworkMode(1, NetworkMode.LTE_ONLY) }.exceptionOrNull() + + assertEquals(IllegalStateException::class.java, thrown?.javaClass) + } + + @Test + fun `should pass a set through to the bound service`() = test { + val controller = controller() + bindConnects(controller) + + source.setNetworkMode(1, NetworkMode.LTE_ONLY) + + verify { controller.setNetworkMode(1, NetworkMode.LTE_ONLY.value) } + } + + @Test + fun `should reuse the first caller's connection for a caller waiting on the lock`() = test { + val controller = controller() + every { controller.getCurrentNetworkMode(1) } returns NetworkMode.LTE_ONLY.value + bindNeverCallsBack() + + val first = async { source.getCurrentNetworkMode(1) } + val second = async { source.getCurrentNetworkMode(1) } + runCurrent() + connection.captured.onServiceConnected(null, serviceFor(controller)) + + assertEquals(NetworkMode.LTE_ONLY, first.await()) + assertEquals(NetworkMode.LTE_ONLY, second.await()) + verify(exactly = 1) { RootService.bind(any(), any()) } + } + + @Test + fun `should wait on a bind left in flight by a cancelled caller`() = test { + val controller = controller() + every { controller.getCurrentNetworkMode(1) } returns NetworkMode.LTE_ONLY.value + bindNeverCallsBack() + + val cancelled = launch { source.getCurrentNetworkMode(1) } + runCurrent() + verify(exactly = 1) { RootService.bind(any(), any()) } + cancelled.cancel() + val retry = async { source.getCurrentNetworkMode(1) } + runCurrent() + connection.captured.onServiceConnected(null, serviceFor(controller)) + + assertEquals(NetworkMode.LTE_ONLY, retry.await()) + verify(exactly = 1) { RootService.bind(any(), any()) } + } + + @Test + fun `should rebind when the cached service has died`() = test { + bindConnects(controller(alive = false), controller()) + + source.getCurrentNetworkMode(1) + source.getCurrentNetworkMode(1) + + verify(exactly = 2) { RootService.bind(any(), any()) } + } + + @Test + fun `should bind afresh after a timeout and still reuse a late connection`() = test { + val controller = controller() + every { controller.getCurrentNetworkMode(1) } returns NetworkMode.LTE_ONLY.value + bindNeverCallsBack() + + assertEquals(null, source.getCurrentNetworkMode(1)) + val first = connection.captured + assertEquals(null, source.getCurrentNetworkMode(1)) + first.onServiceConnected(null, serviceFor(controller)) + + assertEquals(NetworkMode.LTE_ONLY, source.getCurrentNetworkMode(1)) + verify(exactly = 2) { RootService.bind(any(), any()) } + } +}