Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5990024
feat: Implement SIM card management with selection and preferences
aunchagaonkar Oct 19, 2025
2880da1
feat: Implement effective subscription ID handling for network operat…
aunchagaonkar Oct 19, 2025
56e076b
feat: Improve SIM selection handling with validation and error manage…
aunchagaonkar Oct 19, 2025
1f3839d
Merge branch 'main' into feature/support-multiple-sim
aunchagaonkar Oct 19, 2025
725e115
feat: Add AboutActivity and integrate navigation from SettingsActivity
aunchagaonkar Oct 20, 2025
131533c
feat: Add SIM Switch Quick Settings Tile for multi-SIM cycling
aunchagaonkar Jul 8, 2026
6e4a218
Merge branch 'main' into feature/support-multiple-sim
aunchagaonkar Aug 17, 2026
0474bd6
fix: distinguish an unreadable SIM list from an empty one
aunchagaonkar Aug 17, 2026
8763e77
refactor: resolve the effective subscription id in one place
aunchagaonkar Aug 17, 2026
6c6a4b2
feat: track SIM changes live in the SIM tile
aunchagaonkar Aug 17, 2026
d6db1dd
refactor: name the Auto SIM selection instead of repeating -1
aunchagaonkar Aug 17, 2026
2d79463
refactor: drop unreachable API guards and log SIM query failures
aunchagaonkar Aug 18, 2026
e119562
refactor: move SIM tile labels into string resources
aunchagaonkar Aug 20, 2026
6315616
fix: refresh network mode alongside the compatibility check
aunchagaonkar Aug 22, 2026
e956470
feat: add quick settings sim icon
aunchagaonkar Aug 27, 2026
fc9c37a
fix: derive the toggle target from the mode the radio is on
aunchagaonkar Sep 1, 2026
d0a2b27
fix: bound the Shizuku bind and fail without a binder
aunchagaonkar Sep 2, 2026
e3a6d47
test: cover SimRepositoryImpl against a Robolectric SubscriptionManager
aunchagaonkar Aug 29, 2026
d695cf4
chore: refactor comments
aunchagaonkar Sep 1, 2026
ff6b90f
feat: store the toggle configuration per SIM
aunchagaonkar Sep 3, 2026
809639d
feat: group the common network modes for selection
aunchagaonkar Sep 19, 2026
fe33d76
feat: show which SIM the toggle and config screen act on
aunchagaonkar Sep 21, 2026
a993d8d
fix: keep the toggle working when the mode reads back differently
aunchagaonkar Sep 27, 2026
5b68131
fix: bound the root bind and bind on the main thread
aunchagaonkar Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Required to enumerate SIMs; requested at runtime from SettingsActivity -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_BASIC_PHONE_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_PHONE_STATE" tools:ignore="ProtectedPermissions" />
Expand Down Expand Up @@ -38,6 +40,13 @@
android:parentActivityName=".presentation.ui.activity.MainActivity"
android:theme="@style/Theme.NetworkSwitch" />

<activity
android:name=".presentation.ui.activity.AboutActivity"
android:exported="false"
android:label="About"
android:parentActivityName=".presentation.ui.activity.SettingsActivity"
android:theme="@style/Theme.NetworkSwitch" />

<activity
android:name=".presentation.ui.activity.NetworkModeConfigActivity"
android:exported="false"
Expand All @@ -59,6 +68,17 @@
android:value="true" />
</service>

<service
android:name=".service.SimSwitchTileService"
android:exported="true"
android:icon="@drawable/ic_sim_switch"
android:label="@string/sim_switch"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<!-- Shizuku Provider -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.supernova.networkswitch.data.source.RootNetworkControlDataSource
import com.supernova.networkswitch.data.source.ShizukuNetworkControlDataSource
import com.supernova.networkswitch.domain.model.CompatibilityState
import com.supernova.networkswitch.domain.model.ControlMethod
import kotlinx.coroutines.CancellationException
import com.supernova.networkswitch.domain.model.NetworkMode
import com.supernova.networkswitch.domain.repository.NetworkControlRepository
import com.supernova.networkswitch.domain.repository.PreferencesRepository
Expand All @@ -21,17 +22,17 @@ class NetworkControlRepositoryImpl @Inject constructor(
private val preferencesRepository: PreferencesRepository
) : NetworkControlRepository {

override suspend fun checkCompatibility(method: ControlMethod): CompatibilityState {
val dataSource = getDataSource(method)
val subId = android.telephony.SubscriptionManager.getDefaultDataSubscriptionId()
return dataSource.checkCompatibility(subId)
override suspend fun checkCompatibility(method: ControlMethod, subId: Int): CompatibilityState {
return getDataSource(method).checkCompatibility(subId)
}

override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? {
return try {
val method = preferencesRepository.getControlMethod()
val dataSource = getDataSource(method)
dataSource.getCurrentNetworkMode(subId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
Expand All @@ -43,19 +44,13 @@ class NetworkControlRepositoryImpl @Inject constructor(
val dataSource = getDataSource(method)
dataSource.setNetworkMode(subId, mode)
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}

/**
* Reset connections for both data sources - useful when switching control methods
*/
override suspend fun resetConnections() {
rootDataSource.resetConnection()
shizukuDataSource.resetConnection()
}

private fun getDataSource(method: ControlMethod): NetworkControlDataSource {
return when (method) {
ControlMethod.ROOT -> rootDataSource
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ToggleModeConfig> {
return preferencesDataSource.observeToggleModeConfig()
override fun observeToggleModeConfig(simKey: String): Flow<ToggleModeConfig> {
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<Int> {
return preferencesDataSource.observeSelectedSubscriptionId()
}
}
Original file line number Diff line number Diff line change
@@ -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<SimQueryResult> = 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}"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading
Loading