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/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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e77264..b156fac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + @@ -38,6 +40,13 @@ android:parentActivityName=".presentation.ui.activity.MainActivity" android:theme="@style/Theme.NetworkSwitch" /> + + + + + + + + rootDataSource 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 7f84c4d..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 @@ -28,15 +29,35 @@ 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 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() + } + + 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..f17d988 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/SimRepositoryImpl.kt @@ -0,0 +1,146 @@ +package com.supernova.networkswitch.data.repository + +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 +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 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 + +/** + * 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 companion object { + const val TAG = "NetworkSwitch" + } + + private val subscriptionManager: SubscriptionManager? by lazy { + context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager + } + + override suspend fun getAvailableSimCards(): SimQueryResult = querySims() + + /** + * The platform invokes a newly registered listener once immediately, which supplies + * the initial emission. + */ + 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()) + 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) } + } + + // 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) } } + } + + private fun querySims(): SimQueryResult { + if (!hasReadPhoneStatePermission()) { + return SimQueryResult.PermissionDenied + } + + val manager = subscriptionManager + ?: return SimQueryResult.Failed(IllegalStateException("SubscriptionManager unavailable")) + + return try { + val subscriptions = manager.activeSubscriptionInfoList ?: emptyList() + + SimQueryResult.Loaded( + subscriptions + .mapNotNull { mapToSimInfo(it) } + .sortedBy { it.simSlotIndex } + ) + } catch (e: SecurityException) { + Log.w(TAG, "SIM query denied", e) + SimQueryResult.PermissionDenied + } catch (e: Exception) { + Log.w(TAG, "SIM query failed", e) + SimQueryResult.Failed(e) + } + } + + private fun hasReadPhoneStatePermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * 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 { + 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. + * + * 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 { + 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}" + } + } +} 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/PreferencesDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt index e8d8f02..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 @@ -5,11 +5,12 @@ 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 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,15 +23,18 @@ 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 val TOGGLE_NEXT_IS_B_KEY = booleanPreferencesKey("toggle_next_is_b") + + 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" 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 } private fun parseControlMethod(methodString: String?): ControlMethod { @@ -56,40 +60,72 @@ class PreferencesDataSource @Inject constructor( fun observeControlMethod(): Flow { return dataStore.data.map { preferences -> parseControlMethod(preferences[CONTROL_METHOD_KEY]) + }.distinctUntilChanged() + } + + suspend fun getToggleModeConfig(simKey: String): ToggleModeConfig { + return observeToggleModeConfig(simKey).first() + } + + suspend fun setToggleModeConfig(simKey: String, config: ToggleModeConfig) { + dataStore.edit { preferences -> + preferences[modeAKey(simKey)] = config.modeA.value + preferences[modeBKey(simKey)] = config.modeB.value + } + } + + 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 + ) + } + + 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 } } + - suspend fun getToggleModeConfig(): ToggleModeConfig { + /** + * Get the selected subscription ID for the SIM card + * Returns [SubscriptionSelection.AUTO] if no specific SIM is selected + */ + suspend fun getSelectedSubscriptionId(): Int { 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) + preferences[SELECTED_SUBSCRIPTION_ID_KEY] ?: DEFAULT_SUBSCRIPTION_ID }.first() } - suspend fun setToggleModeConfig(config: ToggleModeConfig) { + /** + * Set the selected subscription ID for the SIM card + * Pass [SubscriptionSelection.AUTO] to use the default subscription + */ + suspend fun setSelectedSubscriptionId(subscriptionId: Int) { 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 + preferences[SELECTED_SUBSCRIPTION_ID_KEY] = subscriptionId } } - fun observeToggleModeConfig(): Flow { + /** + * Observe changes to the selected subscription ID + */ + fun observeSelectedSubscriptionId(): 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 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) - } + 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 26b1748..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,38 +6,57 @@ 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 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 { - return if (!Utils.isRootGranted()) { - CompatibilityState.PermissionDenied(com.supernova.networkswitch.domain.model.ControlMethod.ROOT) - } else { + // 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 can bind. + return if (getNetworkController() != null) { CompatibilityState.Compatible + } else { + CompatibilityState.Incompatible("Root service unavailable") } } 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 @@ -46,62 +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 3cf8e39..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,8 +10,9 @@ 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 import rikka.shizuku.Shizuku import javax.inject.Inject import javax.inject.Singleton @@ -24,18 +25,23 @@ 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 { 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,9 +55,16 @@ 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: CancellationException) { + throw e } catch (e: Exception) { CompatibilityState.Incompatible("Shizuku not available: ${e.message}") } @@ -72,35 +85,20 @@ 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() { + + private fun resetConnection() { userService = null _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,13 +108,16 @@ 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 { + withTimeoutOrNull(BIND_TIMEOUT_MS) { suspendCancellableCoroutine { continuation -> val args = Shizuku.UserServiceArgs(ComponentName(context, ShizukuControllerService::class.java)) .processNameSuffix("service") @@ -145,11 +146,6 @@ class ShizukuNetworkControlDataSource @Inject constructor( } } - continuation.invokeOnCancellation { - userService = null - _isConnected.value = false - } - try { Shizuku.bindUserService(args, serviceConnection) } catch (e: Exception) { @@ -158,6 +154,9 @@ class ShizukuNetworkControlDataSource @Inject constructor( } } } + } ?: false + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _isConnected.value = false false 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..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 @@ -59,6 +66,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() } } @@ -67,25 +89,77 @@ 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) + /** + * Matches modes by [NetworkMode.sameRadiosAs]. When [actual] is unreadable, alternates + * from [lastApplied]; a mode outside the pair restarts at [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() 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 +) + +/** + * 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 +} + +/** + * 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. + * + * [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 047b601..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 @@ -3,6 +3,9 @@ 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.SimQueryResult +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.domain.model.ToggleModeConfig import kotlinx.coroutines.flow.Flow @@ -13,7 +16,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 @@ -24,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() } /** @@ -53,15 +51,59 @@ 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 + + /** + * 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 + */ + suspend fun getSelectedSubscriptionId(): Int + + /** + * Set the selected subscription ID for the SIM card + * Pass [SubscriptionSelection.AUTO] 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 { + /** + * 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(): 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/domain/usecase/NetworkUseCases.kt b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt index 2b37404..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 @@ -1,20 +1,27 @@ 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.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( 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()) } } @@ -22,41 +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() - - // 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) + val simKey = SimConfigKey.forSubscription(subId) + val config = preferencesRepository.getToggleModeConfig(simKey) + val actual = networkControlRepository.getCurrentNetworkMode(subId) + val target = config.resolve(actual, preferencesRepository.getLastAppliedMode(simKey)).next + + 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. } - - result.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) + ) } } @@ -71,24 +78,112 @@ class UpdateControlMethodUseCase @Inject constructor( } } +class GetToggleModeConfigUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(subId: Int): ToggleModeConfig { + return preferencesRepository.getToggleModeConfig(SimConfigKey.forSubscription(subId)) + } +} + +class UpdateToggleModeConfigUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(subId: Int, config: ToggleModeConfig) { + preferencesRepository.setToggleModeConfig(SimConfigKey.forSubscription(subId), config) + } +} + /** - * Use case for getting toggle mode configuration + * Use case for getting available SIM cards in the device */ -class GetToggleModeConfigUseCase @Inject constructor( +class GetAvailableSimsUseCase @Inject constructor( + private val simRepository: SimRepository +) { + suspend operator fun invoke(): SimQueryResult { + return try { + simRepository.getAvailableSimCards() + } catch (e: Exception) { + SimQueryResult.Failed(e) + } + } +} + +/** + * 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 + */ +class GetSelectedSubscriptionIdUseCase @Inject constructor( private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(): ToggleModeConfig { - return preferencesRepository.getToggleModeConfig() + suspend operator fun invoke(): Int { + return preferencesRepository.getSelectedSubscriptionId() } } /** - * Use case for updating toggle mode configuration + * Use case for setting the selected subscription ID */ -class UpdateToggleModeConfigUseCase @Inject constructor( +class SetSelectedSubscriptionIdUseCase @Inject constructor( private val preferencesRepository: PreferencesRepository ) { - suspend operator fun invoke(config: ToggleModeConfig) { - preferencesRepository.setToggleModeConfig(config) + suspend operator fun invoke(subscriptionId: Int) { + preferencesRepository.setSelectedSubscriptionId(subscriptionId) + } +} + +/** + * Use case for getting the effective subscription ID to use for network operations + * Returns the user's selected subscription ID, or the default if "Auto" is selected + * Includes validation to handle edge cases like removed SIM cards + */ +class GetEffectiveSubscriptionIdUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository, + private val simRepository: SimRepository +) { + suspend operator fun invoke(): Int { + val selectedSubId = preferencesRepository.getSelectedSubscriptionId() + + if (selectedSubId == SubscriptionSelection.AUTO) { + return SubscriptionManager.getDefaultDataSubscriptionId() + } + + val query = try { + simRepository.getAvailableSimCards() + } catch (e: Exception) { + SimQueryResult.Failed(e) + } + + // 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 { + try { + preferencesRepository.setSelectedSubscriptionId(SubscriptionSelection.AUTO) + } catch (e: Exception) { + // 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/ui/activity/AboutActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt new file mode 100644 index 0000000..fc58e44 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/AboutActivity.kt @@ -0,0 +1,593 @@ +package com.supernova.networkswitch.presentation.ui.activity + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.supernova.networkswitch.BuildConfig +import com.supernova.networkswitch.R +import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme +import kotlinx.coroutines.launch + +class AboutActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + NetworkSwitchTheme { + AboutScreen( + onBackClick = { finish() } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AboutScreen( + onBackClick: () -> Unit +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("About") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // App Info Header + AppInfoHeader() + + // App Version + AppVersionCard( + onCopyVersion = { + copyToClipboard( + context = context, + label = "App Version", + text = "NetworkSwitch v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + ) + scope.launch { + snackbarHostState.showSnackbar("Version copied to clipboard") + } + } + ) + + // Author Card + AuthorCard() + + // Source Code Card + SourceCodeCard() + + // Open Source Licenses + OpenSourceLicensesCard() + + // Device Info (Expandable) + DeviceInfoCard( + onCopyDeviceInfo = { + val deviceInfo = buildDeviceInfoString() + copyToClipboard( + context = context, + label = "Device Info", + text = deviceInfo + ) + scope.launch { + snackbarHostState.showSnackbar("Device info copied to clipboard") + } + } + ) + } + } +} + +@Composable +private fun AppInfoHeader() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + color = MaterialTheme.colorScheme.primary + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_5g_big), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "NetworkSwitch", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "One-Tap network switching", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +private fun AppVersionCard( + onCopyVersion: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCopyVersion) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Version", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onCopyVersion) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy version", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun AuthorCard() { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = "Developer", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Ameya Unchagaonkar", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/aunchagaonkar")) + ) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "github.com/aunchagaonkar", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open GitHub profile", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +private fun SourceCodeCard() { + val context = LocalContext.current + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/aunchagaonkar/NetworkSwitch")) + ) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Source Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "View on GitHub", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open source code", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun OpenSourceLicensesCard() { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Open Source Licenses", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp)) + + LicenseItem( + title = "Shizuku", + license = "Apache License 2.0", + url = "https://github.com/RikkaApps/Shizuku" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "libsu", + license = "Apache License 2.0", + url = "https://github.com/topjohnwu/libsu" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Android Jetpack", + license = "Apache License 2.0", + url = "https://android.googlesource.com/platform/frameworks/support" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Kotlin", + license = "Apache License 2.0", + url = "https://github.com/JetBrains/kotlin" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LicenseItem( + title = "Hilt", + license = "Apache License 2.0", + url = "https://github.com/google/dagger" + ) + } + } + } + } +} + +@Composable +private fun LicenseItem( + title: String, + license: String, + url: String +) { + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open link", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + Text( + text = license, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DeviceInfoCard( + onCopyDeviceInfo: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Device Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp)) + + DeviceInfoRow("Device", Build.DEVICE) + DeviceInfoRow("Model", Build.MODEL) + DeviceInfoRow("Manufacturer", Build.MANUFACTURER) + DeviceInfoRow("Brand", Build.BRAND) + DeviceInfoRow("Android Version", Build.VERSION.RELEASE) + DeviceInfoRow("SDK Level", Build.VERSION.SDK_INT.toString()) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onCopyDeviceInfo, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Copy Device Info") + } + } + } + } + } +} + +@Composable +private fun DeviceInfoRow( + label: String, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + } +} + +private fun copyToClipboard(context: Context, label: String, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) +} + +private fun buildDeviceInfoString(): String { + return """ + Device: ${Build.DEVICE} + Model: ${Build.MODEL} + Manufacturer: ${Build.MANUFACTURER} + Brand: ${Build.BRAND} + Android Version: ${Build.VERSION.RELEASE} + SDK Level: ${Build.VERSION.SDK_INT} + App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + """.trimIndent() +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt index 1c08c2c..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 @@ -103,7 +103,9 @@ private fun MainScreen( if (compatibilityState is CompatibilityState.Compatible) { NetworkToggleCard( 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/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/activity/SettingsActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt index f7d08a7..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 @@ -1,10 +1,15 @@ package com.supernova.networkswitch.presentation.ui.activity +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -13,21 +18,28 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.model.SubscriptionSelection import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme import com.supernova.networkswitch.presentation.viewmodel.SettingsViewModel import dagger.hilt.android.AndroidEntryPoint +import com.supernova.networkswitch.presentation.ui.activity.AboutActivity @AndroidEntryPoint class SettingsActivity : ComponentActivity() { @@ -55,6 +67,71 @@ private fun SettingsScreen( onBackClick: () -> Unit ) { val controlMethod by viewModel.controlMethod.collectAsState() + val availableSims by viewModel.availableSims.collectAsState() + val selectedSubscriptionId by viewModel.selectedSubscriptionId.collectAsState() + val isLoadingSims by viewModel.isLoadingSims.collectAsState() + val simError by viewModel.simError.collectAsState() + + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + // Show error message in snackbar when error occurs + LaunchedEffect(simError) { + simError?.let { error -> + val result = snackbarHostState.showSnackbar( + message = error, + duration = SnackbarDuration.Long + ) + // Clear the error only after the snackbar is dismissed + if (result == SnackbarResult.Dismissed || result == SnackbarResult.ActionPerformed) { + viewModel.clearSimError() + } + } + } + var hasPhoneStatePermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + ) + } + + var showPermissionRationaleDialog by remember { mutableStateOf(false) } + + // Permission launcher + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasPhoneStatePermission = isGranted + if (isGranted) { + // Refresh SIM list after permission is granted + viewModel.refreshAvailableSims() + } + } + + // Extracted permission request logic + fun requestPhoneStatePermission() { + if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + showPermissionRationaleDialog = true + } + } + + // Show permission rationale dialog on first composition if permission not granted + LaunchedEffect(Unit) { + requestPhoneStatePermission() + } + + // Permission Rationale Dialog + if (showPermissionRationaleDialog) { + PermissionRationaleDialog( + onDismiss = { showPermissionRationaleDialog = false }, + onConfirm = { + showPermissionRationaleDialog = false + permissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + } + ) + } Scaffold( topBar = { @@ -69,7 +146,8 @@ private fun SettingsScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> Column( modifier = Modifier @@ -88,8 +166,209 @@ private fun SettingsScreen( onRetryClick = { viewModel.retryCompatibilityCheck() } ) - // About Section - AboutCard() + // SIM Card Selection + // Show if multiple SIMs detected OR if permission not granted (to show info card) + if (availableSims.size > 1) { + SimSelectionCard( + availableSims = availableSims, + selectedSubscriptionId = selectedSubscriptionId, + isLoading = isLoadingSims, + onSimSelected = { viewModel.selectSim(it) }, + onRefresh = { viewModel.refreshAvailableSims() } + ) + } else if (!hasPhoneStatePermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Show permission info card + PermissionInfoCard( + onRequestPermission = { + requestPhoneStatePermission() + } + ) + } + + // Permissions Card + PermissionsCard( + hasPhoneStatePermission = hasPhoneStatePermission, + onRequestPermission = { + requestPhoneStatePermission() + } + ) + + // About Section - Button to navigate to About Activity + AboutNavigationCard( + onNavigateToAbout = { + context.startActivity(Intent(context, AboutActivity::class.java)) + } + ) + } + } +} + +@Composable +private fun PermissionRationaleDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "Multi-SIM Support Permission", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column { + Text( + text = "NetworkSwitch needs access to read your phone state to detect and manage multiple SIM cards on your device.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "This permission allows the app to:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "• Identify available SIM cards", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Display SIM card names and operators", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Allow you to choose which SIM to control", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Your privacy is important. This permission is only used to identify SIM cards and is never used to access your calls, messages, or contacts.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text("Grant Permission") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Not Now") + } + } + ) +} + +@Composable +private fun PermissionsCard( + hasPhoneStatePermission: Boolean, + onRequestPermission: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Permissions", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Manage app permissions to enable all features", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Phone State Permission Item + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !hasPhoneStatePermission) { + onRequestPermission() + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (hasPhoneStatePermission) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (hasPhoneStatePermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Phone State", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = if (hasPhoneStatePermission) "Granted - Multi-SIM support enabled" else "Not granted - Tap to request", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun AboutNavigationCard( + onNavigateToAbout: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onNavigateToAbout) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "About", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "App information, licenses, and more", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate to About", + modifier = Modifier.size(24.dp) + ) } } } @@ -270,8 +549,17 @@ private fun ControlMethodCard( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AboutCard() { +private fun SimSelectionCard( + availableSims: List, + selectedSubscriptionId: Int, + isLoading: Boolean, + onSimSelected: (Int) -> Unit, + onRefresh: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Card( modifier = Modifier.fillMaxWidth() ) { @@ -280,90 +568,202 @@ private fun AboutCard() { .fillMaxWidth() .padding(16.dp) ) { - Text( - text = "About", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Source Code", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "SIM Card Selection", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + if (!isLoading) { + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh SIM list" + ) + } + } + } Spacer(modifier = Modifier.height(8.dp)) - LinkItem( - title = "NetworkSwitch", - subtitle = "https://github.com/aunchagaonkar/NetworkSwitch", - link = "https://github.com/aunchagaonkar/NetworkSwitch" - ) - - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Open Source Licenses", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - LinkItem( - title = "Shizuku", - subtitle = "Apache License 2.0\nhttps://github.com/RikkaApps/Shizuku", - link = "https://github.com/RikkaApps/Shizuku" - ) - - LinkItem( - title = "libsu", - subtitle = "Apache License 2.0\nhttps://github.com/topjohnwu/libsu", - link = "https://github.com/topjohnwu/libsu" + text = "Choose which SIM card to use for network switching. The app will only change network settings for the selected SIM.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - LinkItem( - title = "Android Jetpack", - subtitle = "Apache License 2.0\nhttps://android.googlesource.com/platform/frameworks/support", - link = "https://android.googlesource.com/platform/frameworks/support" - ) + Spacer(modifier = Modifier.height(16.dp)) - LinkItem( - title = "Kotlin", - subtitle = "Apache License 2.0\nhttps://github.com/JetBrains/kotlin", - link = "https://github.com/JetBrains/kotlin" - ) + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // Dropdown Menu + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = getSelectedSimDisplayName(availableSims, selectedSubscriptionId), + onValueChange = {}, + readOnly = true, + label = { Text("Selected SIM") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + // Option for "Auto/Default" + DropdownMenuItem( + text = { + Column { + Text( + text = "Auto (System Default)", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Let the system choose", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onSimSelected(SubscriptionSelection.AUTO) + expanded = false + }, + leadingIcon = { + if (selectedSubscriptionId == SubscriptionSelection.AUTO) { + 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 == SubscriptionSelection.AUTO) { + return "Auto (System Default)" + } + return availableSims.find { it.subscriptionId == selectedSubscriptionId }?.displayName + ?: "Unknown SIM" +} + @Composable -private fun LinkItem( - title: String, - subtitle: String, - link: String +private fun PermissionInfoCard( + onRequestPermission: () -> Unit ) { - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) - } - .padding(vertical = 8.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Multi-SIM Support", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "To detect and manage multiple SIM cards, this app needs permission to read your phone state. This permission is only used to identify available SIM cards.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRequestPermission, + modifier = Modifier.fillMaxWidth() + ) { + Text("Grant Permission") + } + } } } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt index 36c23b3..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 @@ -123,7 +123,9 @@ fun CompatibilityCard( @Composable fun NetworkToggleCard( currentMode: NetworkMode?, + activeSimName: String?, toggleButtonText: String, + canToggle: Boolean, isLoading: Boolean, onToggleClick: () -> Unit, modifier: Modifier = Modifier @@ -133,6 +135,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", @@ -157,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 f59b820..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 @@ -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 @@ -9,172 +8,165 @@ 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.ToggleModeConfig +import com.supernova.networkswitch.domain.model.SimConfigKey +import com.supernova.networkswitch.domain.model.ToggleState import com.supernova.networkswitch.domain.usecase.CheckCompatibilityUseCase -import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase +import com.supernova.networkswitch.domain.usecase.GetSimNameUseCase +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.usecase.GetToggleModeConfigUseCase 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 import dagger.hilt.android.lifecycle.HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) @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, - private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, + private val getSimNameUseCase: GetSimNameUseCase, 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 + private var nextMode by mutableStateOf(null) + + val canToggle: Boolean + get() = nextMode != null var isLoading by mutableStateOf(false) private set - // Compatibility state using domain model 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 + + init { observeControlMethodPreference() - loadToggleModeConfig() - checkCompatibility() - refreshNetworkState() + refresh() } - /** - * Observe control method preference changes - */ private fun observeControlMethodPreference() { viewModelScope.launch { preferencesRepository.observeControlMethod().collectLatest { preferredMethod -> if (selectedMethod != preferredMethod) { selectedMethod = preferredMethod - checkCompatibility() - loadToggleModeConfig() - refreshNetworkState() + refresh() } } } - // Also observe toggle mode config changes viewModelScope.launch { - preferencesRepository.observeToggleModeConfig().collectLatest { newConfig -> - toggleModeConfig = newConfig - // Force UI update when config changes - refreshNetworkState() - } + preferencesRepository.observeSelectedSubscriptionId() + .mapLatest { SimConfigKey.forSubscription(getEffectiveSubscriptionIdUseCase()) } + .distinctUntilChanged() + .flatMapLatest { preferencesRepository.observeToggleModeConfig(it) } + .drop(1) // init already refreshes + .collect { refresh() } + } + + viewModelScope.launch { + preferencesRepository.observeSelectedSubscriptionId() + .collectLatest { loadActiveSim() } } } + + private suspend fun loadActiveSim() { + activeSimName = getSimNameUseCase(getEffectiveSubscriptionIdUseCase()) + } /** - * Check compatibility using domain use case + * 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 checkCompatibility() { - viewModelScope.launch { + private fun refresh() { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { compatibilityState = CompatibilityState.Pending - compatibilityState = checkCompatibilityUseCase() + val state = checkCompatibilityUseCase() + val toggle = if (state is CompatibilityState.Compatible) readToggleState() else null + compatibilityState = state + showToggleState(toggle) } } + + 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 + } - /** - * Switch control method - */ fun switchToMethod(method: ControlMethod) { viewModelScope.launch { updateControlMethodUseCase(method) } } - /** - * Retry compatibility check - */ fun retryCompatibilityCheck() { - checkCompatibility() + refresh() } - /** - * Refresh all data when app resumes - */ + /** Called on resume; the activity has no other signal that state may be stale. */ fun refreshAllData() { - checkCompatibility() - refreshNetworkState() + refresh() + viewModelScope.launch { loadActiveSim() } } - /** - * 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 { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() + 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 { - // On failure, refresh state to get current status - refreshNetworkState() + refresh() } isLoading = false } } - /** - * Get display text for current network mode and next toggle mode - */ fun getToggleButtonText(): String { - val nextMode = toggleModeConfig.getNextMode() - return "Switch to ${nextMode.displayName}" + val next = nextMode ?: return "Switch" + return "Switch to ${next.displayName}" } - /** - * Refresh current network state - */ - private fun refreshNetworkState() { - viewModelScope.launch { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() - - getCurrentNetworkModeUseCase(subId) - .onSuccess { mode -> - currentNetworkMode = mode - } - .onFailure { - currentNetworkMode = null - } - } - } } 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..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 @@ -4,6 +4,8 @@ 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.GetSimNameUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.usecase.UpdateToggleModeConfigUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,7 +21,9 @@ 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, + private val getSimName: GetSimNameUseCase ) : ViewModel() { private val _currentConfig = MutableStateFlow( @@ -32,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() @@ -40,8 +54,10 @@ class NetworkModeConfigViewModel @Inject constructor( private fun loadCurrentConfiguration() { viewModelScope.launch { try { - val config = getToggleModeConfigUseCase() - _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) @@ -61,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(_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/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt index 147885b..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 @@ -7,10 +7,20 @@ 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.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 +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 +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.async @@ -22,7 +32,11 @@ 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 getEffectiveSubscriptionId: GetEffectiveSubscriptionIdUseCase, + private val getSelectedSubscriptionIdUseCase: GetSelectedSubscriptionIdUseCase, + private val setSelectedSubscriptionIdUseCase: SetSelectedSubscriptionIdUseCase ) : ViewModel() { val controlMethod: StateFlow = preferencesRepository.observeControlMethod() @@ -39,8 +53,30 @@ class SettingsViewModel @Inject constructor( var shizukuCompatibility by mutableStateOf(CompatibilityState.Pending) private set + // Available SIM cards in the device + private val _availableSims = MutableStateFlow>(emptyList()) + val availableSims: StateFlow> = _availableSims.asStateFlow() + + // Currently selected subscription ID + val selectedSubscriptionId: StateFlow = preferencesRepository.observeSelectedSubscriptionId() + .stateIn( + scope = viewModelScope, + started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), + initialValue = SubscriptionSelection.AUTO + ) + + // Loading state for SIM detection + private val _isLoadingSims = MutableStateFlow(false) + val isLoadingSims: StateFlow = _isLoadingSims.asStateFlow() + + // Error state for SIM operations + private val _simError = MutableStateFlow(null) + val simError: StateFlow = _simError.asStateFlow() + init { checkAllCompatibility() + loadAvailableSims() + observeSelectedSimValidity() } fun updateControlMethod(method: ControlMethod) { @@ -58,12 +94,143 @@ 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() } } + + /** + * Load all available SIM cards in the device + */ + private fun loadAvailableSims() { + viewModelScope.launch { + _isLoadingSims.value = true + _simError.value = null + try { + 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() + _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 + + if (currentSelection == SubscriptionSelection.AUTO) 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(SubscriptionSelection.AUTO) + _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 != SubscriptionSelection.AUTO && _availableSims.value.isNotEmpty()) { + val isValid = _availableSims.value.any { it.subscriptionId == subId } + if (!isValid) { + simValidationMutex.lock() + try { + validateSelectedSim(_availableSims.value) + } finally { + simValidationMutex.unlock() + } + } + } + } + } + } + + /** + * Refresh the list of available SIM cards + * Useful when permission is granted or SIM cards are changed + */ + fun refreshAvailableSims() { + loadAvailableSims() + } + + /** + * Select a specific SIM card for network operations + * @param subscriptionId The subscription ID of the SIM to select, or [SubscriptionSelection.AUTO] + */ + fun selectSim(subscriptionId: Int) { + viewModelScope.launch { + try { + // Validate the selection before saving + if (subscriptionId != SubscriptionSelection.AUTO) { + val isValid = _availableSims.value.any { it.subscriptionId == subscriptionId } + if (!isValid) { + _simError.value = "Selected SIM is not available" + return@launch + } + } + + setSelectedSubscriptionIdUseCase(subscriptionId) + _simError.value = null // Clear any previous errors + } catch (e: Exception) { + _simError.value = "Failed to save SIM selection: ${e.message}" + } + } + } + + /** + * Clear any SIM-related error messages + */ + fun clearSimError() { + _simError.value = null + } + + /** + * Get the currently selected SIM info object + * Returns null if no specific SIM is selected or if the selected SIM is not available + */ + fun getSelectedSimInfo(): SimInfo? { + val currentSubscriptionId = selectedSubscriptionId.value + if (currentSubscriptionId == 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/NetworkTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt index 420aa39..1402cea 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -2,30 +2,40 @@ package com.supernova.networkswitch.service import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import android.telephony.SubscriptionManager import android.util.Log import com.supernova.networkswitch.R -import com.supernova.networkswitch.domain.model.NetworkMode -import com.supernova.networkswitch.domain.model.ToggleModeConfig +import com.supernova.networkswitch.domain.model.SimConfigKey +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 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() { @Inject - lateinit var getCurrentNetworkModeUseCase: GetCurrentNetworkModeUseCase + lateinit var getToggleStateUseCase: GetToggleStateUseCase + @Inject + lateinit var getEffectiveSubscriptionIdUseCase: GetEffectiveSubscriptionIdUseCase + @Inject lateinit var toggleNetworkModeUseCase: ToggleNetworkModeUseCase @@ -38,24 +48,34 @@ 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 + private var toggleState: ToggleState? = 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 { 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() @@ -74,14 +94,13 @@ class NetworkTileService : TileService() { } private fun performToggle() { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() - serviceScope.launch { try { // The binder call into the root/Shizuku process can hang indefinitely. 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) { @@ -93,12 +112,10 @@ class NetworkTileService : TileService() { } private suspend fun refreshNetworkState() { - val subId = SubscriptionManager.getDefaultDataSubscriptionId() - try { - 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) } @@ -110,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 ?: config.getCurrentMode()).displayName - tile.subtitle = getString( - R.string.tile_next_mode, - config.getNextMode().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/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt new file mode 100644 index 0000000..755af78 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/service/SimSwitchTileService.kt @@ -0,0 +1,190 @@ +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 +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 +class SimSwitchTileService : TileService() { + + @Inject + lateinit var preferencesRepository: PreferencesRepository + + @Inject + lateinit var simRepository: SimRepository + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var listeningJob: Job? = null + + private var simQuery: SimQueryResult = SimQueryResult.Loaded(emptyList()) + private var selectedSubId: Int = SubscriptionSelection.AUTO + + private val availableSims: List + get() = (simQuery as? SimQueryResult.Loaded)?.sims ?: emptyList() + + override fun onStartListening() { + super.onStartListening() + listeningJob?.cancel() + listeningJob = serviceScope.launch { + try { + 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 the tile stops listening + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to observe SIM state", e) + } + } + } + + override fun onStopListening() { + super.onStopListening() + listeningJob?.cancel() + listeningJob = null + } + + override fun onClick() { + super.onClick() + + serviceScope.launch { + try { + // 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) { + 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 the tile stops listening + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to cycle SIM", e) + } + } + } + + /** + * Determine the next subscription ID in the cycle. + * Order: Auto → SIM 1 → SIM 2 → ... → Auto + */ + private fun getNextSubscriptionId(currentSubId: Int): Int { + if (availableSims.isEmpty()) return SubscriptionSelection.AUTO + + if (currentSubId == SubscriptionSelection.AUTO) { + // 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 + SubscriptionSelection.AUTO + } else { + // Move to next SIM + availableSims[currentIndex + 1].subscriptionId + } + } + + private fun refreshTileDisplay() { + try { + val tile = qsTile ?: return + + if (simQuery is SimQueryResult.PermissionDenied) { + tile.state = Tile.STATE_INACTIVE + 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 = 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 = getString(R.string.sim_switch) + tile.subtitle = if (availableSims.size == 1) { + availableSims.first().displayName + } else { + getString(R.string.sim_tile_no_sim) + } + tile.updateTile() + return + } + + // Multiple SIMs available — tile is active + tile.state = Tile.STATE_ACTIVE + + if (selectedSubId == SubscriptionSelection.AUTO) { + 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 = 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 = getString(R.string.sim_tile_auto) + tile.subtitle = getString(R.string.sim_tile_previous_removed) + } + } + + tile.updateTile() + } catch (e: Exception) { + Log.e(TAG, "SIM tile: failed to update tile", e) + } + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + private companion object { + const val TAG = "NetworkSwitch" + } +} 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 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e15f41a..65f62fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,9 +9,19 @@ Mode B Save Configuration Configuration saved successfully + Configuring: %1$s - + + SIM Select 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 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/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/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()) } + } +} 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..bdb5bfd --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/data/source/ToggleModeConfigStorageTest.kt @@ -0,0 +1,102 @@ +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)) + } + + @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/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 }) + } +} 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..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,68 +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( + 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( + ToggleState(NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), + config.resolve(NetworkMode.NR_ONLY, null) + ) + } + + @Test + 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 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) + ) + } + + @Test + 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 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 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) + ) } - + @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 mode is unreadable and nothing was applied`() { + assertEquals(ToggleState(null, NetworkMode.LTE_ONLY), config.resolve(null, null)) } - + @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 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 + + assertEquals( + listOf(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, NetworkMode.LTE_ONLY), + listOf(first, second, third) + ) } - + @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 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.resolve(NetworkMode.LTE_ONLY, null).next) + assertEquals(NetworkMode.LTE_ONLY, same.resolve(null, null).next) } -} \ No newline at end of file +} 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..c7472a0 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetAvailableSimsUseCaseTest.kt @@ -0,0 +1,99 @@ +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 +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 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 SimQueryResult.Loaded(simList) + + assertEquals(SimQueryResult.Loaded(simList), useCase()) + } + + @Test + 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 report permission denied distinctly from an empty list`() = runTest { + coEvery { simRepository.getAvailableSimCards() } returns SimQueryResult.PermissionDenied + + val result = useCase() + + assertEquals(SimQueryResult.PermissionDenied, result) + assertNotEquals(SimQueryResult.Loaded(emptyList()), result) + } + + @Test + 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 { + val singleSim = listOf( + SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "My Carrier (Slot 1)") + ) + 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 { + 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 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 new file mode 100644 index 0000000..633d517 --- /dev/null +++ b/app/src/test/java/com/supernova/networkswitch/domain/usecase/GetEffectiveSubscriptionIdUseCaseTest.kt @@ -0,0 +1,175 @@ +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 +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 SimQueryResult.Loaded(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 SimQueryResult.Loaded(availableSims) + coEvery { preferencesRepository.setSelectedSubscriptionId(-1) } returns Unit + + // When + val result = useCase() + + // Then + assertEquals(defaultSubId, result) + 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 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") + + 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 + 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 SimQueryResult.Loaded(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 SimQueryResult.Loaded(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/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/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/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/domain/usecase/ToggleNetworkModeUseCaseTest.kt b/app/src/test/java/com/supernova/networkswitch/domain/usecase/ToggleNetworkModeUseCaseTest.kt index 8d53ff7..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,134 +1,152 @@ 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 with alternating logic - */ class ToggleNetworkModeUseCaseTest { - + private lateinit var networkControlRepository: NetworkControlRepository private lateinit var preferencesRepository: PreferencesRepository 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 fun setUp() { networkControlRepository = mockk() 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 nextModeIsB is true`() = runTest { - // Given - val config = ToggleModeConfig(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY, nextModeIsB = true) - coEvery { preferencesRepository.getToggleModeConfig() } returns config - 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 + fun `should switch to mode B when the SIM is on mode A`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + val result = useCase(testSubId) - - // Then - assertTrue(result.isSuccess) - assertEquals(NetworkMode.NR_ONLY, result.getOrNull()) + + assertEquals(ToggleState(NetworkMode.NR_ONLY, NetworkMode.LTE_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 - 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 + fun `should switch to mode A when the SIM is on mode B`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.NR_ONLY + val result = useCase(testSubId) - - // Then - assertTrue(result.isSuccess) - assertEquals(NetworkMode.LTE_ONLY, result.getOrNull()) + + assertEquals(ToggleState(NetworkMode.LTE_ONLY, NetworkMode.NR_ONLY), result.getOrNull()) coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.LTE_ONLY) } - coVerify { preferencesRepository.setToggleModeConfig(updatedConfig) } } - + + @Test + 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 + + useCase(testSubId) + + coVerify { preferencesRepository.setLastAppliedMode(simKey, NetworkMode.NR_ONLY) } + } + + @Test + fun `should alternate from the last applied mode when the mode is unreadable`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns null + coEvery { preferencesRepository.getLastAppliedMode(simKey) } returns NetworkMode.LTE_ONLY + + useCase(testSubId) + + coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } + } + @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 - 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()) + 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) + + coVerify { networkControlRepository.setNetworkMode(testSubId, NetworkMode.NR_ONLY) } + coVerify { networkControlRepository.setNetworkMode(otherSubId, NetworkMode.LTE_ONLY) } } - + @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 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) - - // 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()) + coVerify(exactly = 0) { preferencesRepository.setLastAppliedMode(any(), any()) } } - + @Test - fun `should handle repository exception`() = runTest { - // Given - coEvery { preferencesRepository.getToggleModeConfig() } throws RuntimeException("Repository error") - - // When + 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) - - // Then - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is RuntimeException) + + 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) + } + + @Test + fun `should not write the toggle configuration`() = runTest { + coEvery { networkControlRepository.getCurrentNetworkMode(testSubId) } returns NetworkMode.LTE_ONLY + + useCase(testSubId) + + coVerify(exactly = 0) { preferencesRepository.setToggleModeConfig(any(), any()) } } -} \ No newline at end of file +} 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()) } + } +} 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..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 @@ -2,8 +2,14 @@ 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 +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 import io.mockk.coEvery import io.mockk.coVerify @@ -24,6 +30,10 @@ 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 private lateinit var viewModel: SettingsViewModel @@ -31,30 +41,42 @@ class SettingsViewModelTest { fun setUp() { preferencesRepository = mockk(relaxed = true) networkControlRepository = mockk(relaxed = true) + getAvailableSimsUseCase = mockk(relaxed = true) + getEffectiveSubscriptionIdUseCase = 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 SimQueryResult.Loaded(emptyList()) + coEvery { getSelectedSubscriptionIdUseCase() } returns -1 + coEvery { getEffectiveSubscriptionIdUseCase() } returns DEFAULT_SUB_ID } private fun createViewModel() { viewModel = SettingsViewModel( preferencesRepository, - networkControlRepository + networkControlRepository, + getAvailableSimsUseCase, + getEffectiveSubscriptionIdUseCase, + getSelectedSubscriptionIdUseCase, + setSelectedSubscriptionIdUseCase ) } @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() @@ -74,7 +96,44 @@ 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 + 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 SimQueryResult.Loaded( + 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 SimQueryResult.Loaded( + listOf(SimInfo(subscriptionId = 1, simSlotIndex = 0, displayName = "SIM 1")) + ) + createViewModel() + viewModel.selectSim(validSimId) + assertEquals(null, viewModel.simError.value) + } + + private companion object { + const val DEFAULT_SUB_ID = 1 } } 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 + } +} 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 {