diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 827be7f42..ceaaa7612 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -59,6 +59,7 @@ android {
release {
isMinifyEnabled = true
isShrinkResources = true
+ signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f243d312d..4c008a1ba 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -374,6 +374,14 @@
android:taskAffinity=""
android:theme="@style/Theme.Essentials.Translucent" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ val subId = intent.getIntExtra("subId", -1)
+ if (subId != -1) {
+ Log.d(TAG, "Setting default data SIM to subId=$subId")
+ setDefaultDataSubId(subId)
+ enableMobileData(context, subId)
+ } else {
+ Log.w(TAG, "Invalid subId extra in SET_DATA_SIM broadcast")
+ }
+ }
+ "com.sameerasw.essentials.SET_NETWORK_MODE" -> {
+ val subId = intent.getIntExtra("subId", -1)
+ val bitmask = intent.getLongExtra("bitmask", -1L)
+ if (subId != -1 && bitmask != -1L) {
+ Log.d(TAG, "Setting network mode for subId=$subId to bitmask=$bitmask")
+ setAllowedNetworkTypes(context, subId, bitmask)
+ } else {
+ Log.w(TAG, "Invalid subId or bitmask extra in SET_NETWORK_MODE broadcast")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling telephony action broadcast", e)
+ }
+ }
+
+ private fun setDefaultDataSubId(subId: Int) {
+ val binder = SystemServiceHelper.getSystemService("isub")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ISub\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iSubInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ val setDefaultDataSubIdMethod = iSubInstance.javaClass.getMethod(
+ "setDefaultDataSubId",
+ Int::class.javaPrimitiveType
+ )
+ setDefaultDataSubIdMethod.invoke(iSubInstance, subId)
+ Log.d(TAG, "Successfully set default data subscription to $subId via standard reflection")
+ } else {
+ Log.e(TAG, "Shizuku isub binder not available")
+ }
+ }
+
+ private fun setAllowedNetworkTypes(context: Context, subId: Int, bitmask: Long) {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try 4-argument signature (Android 14/15/16/17+)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, bitmask, context.packageName)
+ } catch (e: NoSuchMethodException) {
+ // Fall back to 3-argument signature (Android 11/12/13)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, 0, bitmask)
+ } catch (ex: NoSuchMethodException) {
+ Log.e(TAG, "Both 4-argument and 3-argument setAllowedNetworkTypesForReason methods not found", ex)
+ }
+ }
+
+ if (setMethod != null) {
+ setMethod.invoke(iTelephonyInstance, *args)
+ Log.d(TAG, "Successfully set allowed network types to $bitmask via standard reflection")
+ }
+ } else {
+ Log.e(TAG, "Shizuku phone binder not available")
+ }
+ }
+
+ private fun enableMobileData(context: Context, subId: Int) {
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setDataEnabledMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try setDataEnabledForReason(int subId, int reason, boolean enable, String callingPackage) - Android 14+
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabledForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, true, context.packageName)
+ } catch (e: NoSuchMethodException) {
+ // Try setDataEnabled(int subId, boolean enable) - older versions
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabled",
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, true)
+ } catch (ex: NoSuchMethodException) {
+ Log.e(TAG, "Could not find setDataEnabled or setDataEnabledForReason method", ex)
+ }
+ }
+
+ if (setDataEnabledMethod != null) {
+ setDataEnabledMethod.invoke(iTelephonyInstance, *args)
+ Log.d(TAG, "Successfully enabled mobile data for subId=$subId")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error enabling mobile data for subId=$subId", e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/DataSimTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/DataSimTileService.kt
new file mode 100644
index 000000000..0eb20b324
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/DataSimTileService.kt
@@ -0,0 +1,209 @@
+package com.sameerasw.essentials.services.tiles
+
+import android.content.Context
+import com.sameerasw.essentials.utils.TelephonyParser
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.utils.ShellUtils
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+
+@RequiresApi(Build.VERSION_CODES.N)
+class DataSimTileService : BaseTileService() {
+
+ private val TAG = "DataSimTile"
+
+ private val subscriptionReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: android.content.Intent?) {
+ Log.d(TAG, "Broadcast received: ${intent?.action}. Updating tile.")
+ updateTile()
+ }
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ Log.d(TAG, "onStartListening called")
+ val filter = android.content.IntentFilter().apply {
+ addAction("android.intent.action.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED")
+ addAction("android.telephony.action.DEFAULT_SUBSCRIPTION_CHANGED")
+ }
+ registerReceiver(subscriptionReceiver, filter)
+ updateTile()
+ }
+
+ override fun onStopListening() {
+ super.onStopListening()
+ Log.d(TAG, "onStopListening called")
+ try {
+ unregisterReceiver(subscriptionReceiver)
+ } catch (_: Exception) {}
+ }
+
+ override fun onTileClick() {
+ Log.d(TAG, "onTileClick initiated")
+ if (!hasFeaturePermission()) {
+ Log.w(TAG, "No shell/root permission")
+ return
+ }
+
+ val activeSubs = TelephonyParser.parseActiveSims(ShellUtils.runCommandWithOutput(this, "dumpsys isub") ?: "")
+ if (activeSubs.isEmpty()) {
+ Log.w(TAG, "No active subscriptions found from shell")
+ return
+ }
+
+ if (activeSubs.size < 2) {
+ Log.w(TAG, "Only ${activeSubs.size} active SIMs. Toggle requires at least 2.")
+ return
+ }
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ Log.d(TAG, "Current default data subId: $defaultDataSubId")
+
+ val nextSub = activeSubs.firstOrNull { it.id != defaultDataSubId }
+ if (nextSub != null) {
+ val nextSubId = nextSub.id
+ Log.d(TAG, "Targeting next data SIM: subId=$nextSubId, slotIndex=${nextSub.slotIndex}")
+ setDefaultDataSubId(nextSubId)
+ enableMobileData(nextSubId)
+ } else {
+ Log.e(TAG, "Could not find next subscription")
+ }
+ }
+
+ private fun setDefaultDataSubId(subId: Int) {
+ try {
+ val binder = SystemServiceHelper.getSystemService("isub")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ISub\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iSubInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ val setDefaultDataSubIdMethod = iSubInstance.javaClass.getMethod(
+ "setDefaultDataSubId",
+ Int::class.javaPrimitiveType
+ )
+ setDefaultDataSubIdMethod.invoke(iSubInstance, subId)
+ Log.d(TAG, "Successfully set default data subscription to $subId via standard Java reflection")
+ return
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error setting default data sub via standard Java reflection", e)
+ }
+
+ // Fallback for Root/other cases: write setting
+ Log.w(TAG, "Shizuku binder reflection failed. Attempting settings write.")
+ ShellUtils.runCommand(this, "settings put global multi_sim_data_call $subId")
+ }
+
+ private fun enableMobileData(subId: Int) {
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setDataEnabledMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try setDataEnabledForReason(int subId, int reason, boolean enable, String callingPackage) - Android 14+
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabledForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, true, packageName)
+ } catch (e: NoSuchMethodException) {
+ // Try setDataEnabled(int subId, boolean enable) - older versions
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabled",
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, true)
+ } catch (ex: NoSuchMethodException) {
+ Log.e(TAG, "Could not find setDataEnabled or setDataEnabledForReason method", ex)
+ }
+ }
+
+ if (setDataEnabledMethod != null) {
+ setDataEnabledMethod.invoke(iTelephonyInstance, *args)
+ Log.d(TAG, "Successfully enabled mobile data for subId=$subId")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error enabling mobile data for subId=$subId", e)
+ }
+ }
+
+ override fun getTileLabel(): String = getString(R.string.tile_data_sim)
+
+ override fun getTileSubtitle(): String {
+ if (!hasFeaturePermission()) return getString(R.string.permission_missing)
+
+ val activeSubs = TelephonyParser.parseActiveSims(ShellUtils.runCommandWithOutput(this, "dumpsys isub") ?: "")
+ if (activeSubs.isEmpty()) {
+ return getString(R.string.off)
+ }
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val currentSub = activeSubs.find { it.id == defaultDataSubId }
+
+ val subtitle = if (currentSub != null) {
+ val displayName = currentSub.displayName
+ if (displayName.isNotBlank()) {
+ "SIM ${currentSub.slotIndex + 1}: $displayName"
+ } else {
+ "SIM ${currentSub.slotIndex + 1}"
+ }
+ } else {
+ getString(R.string.off)
+ }
+ Log.d(TAG, "getTileSubtitle returning: $subtitle")
+ return subtitle
+ }
+
+ override fun hasFeaturePermission(): Boolean {
+ val hasShell = ShellUtils.isAvailable(this) && ShellUtils.hasPermission(this)
+ Log.d(TAG, "hasFeaturePermission check: hasShell=$hasShell")
+ return hasShell
+ }
+
+ override fun getTileIcon(): Icon {
+ return Icon.createWithResource(this, R.drawable.rounded_android_cell_dual_4_bar_24)
+ }
+
+ override fun getTileState(): Int {
+ if (!hasFeaturePermission()) return Tile.STATE_INACTIVE
+
+ val activeSubs = TelephonyParser.parseActiveSims(ShellUtils.runCommandWithOutput(this, "dumpsys isub") ?: "")
+ if (activeSubs.isEmpty()) return Tile.STATE_INACTIVE
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val currentSub = activeSubs.find { it.id == defaultDataSubId }
+
+ val state = if (currentSub?.slotIndex == 0) {
+ Tile.STATE_ACTIVE
+ } else {
+ Tile.STATE_INACTIVE
+ }
+ Log.d(TAG, "getTileState returning: $state")
+ return state
+ }
+
+ private fun getDefaultDataSubIdFromShell(): Int {
+ val output = ShellUtils.runCommandWithOutput(this, "settings get global multi_sim_data_call")
+ return output?.trim()?.toIntOrNull() ?: -1
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/NetworkModeTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/NetworkModeTileService.kt
new file mode 100644
index 000000000..360875a60
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/NetworkModeTileService.kt
@@ -0,0 +1,301 @@
+package com.sameerasw.essentials.services.tiles
+
+import android.content.Context
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.utils.ShellUtils
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+
+@RequiresApi(Build.VERSION_CODES.N)
+class NetworkModeTileService : BaseTileService() {
+
+ private val TAG = "NetworkModeTile"
+
+ private val subscriptionReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: android.content.Intent?) {
+ Log.d(TAG, "Broadcast received: ${intent?.action}. Updating network mode tile.")
+ updateTile()
+ }
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ val filter = android.content.IntentFilter().apply {
+ addAction("android.intent.action.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED")
+ addAction("android.telephony.action.DEFAULT_SUBSCRIPTION_CHANGED")
+ addAction("android.telephony.action.CARRIER_CONFIG_CHANGED")
+ }
+ registerReceiver(subscriptionReceiver, filter)
+ updateTile()
+ }
+
+ override fun onStopListening() {
+ super.onStopListening()
+ try {
+ unregisterReceiver(subscriptionReceiver)
+ } catch (_: Exception) {}
+ }
+
+ override fun onTileClick() {
+ if (!hasFeaturePermission()) return
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val slotId = getDefaultDataSimSlotFromShell()
+
+ val currentMode = getCurrentNetworkMode(defaultDataSubId, slotId)
+ val nextMode = when (currentMode) {
+ NetworkMode.MODE_NR_ONLY -> NetworkMode.MODE_LTE_ONLY
+ else -> NetworkMode.MODE_NR_ONLY
+ }
+
+ val bitmask = getBitmaskForMode(nextMode)
+ setAllowedNetworkTypes(defaultDataSubId, slotId, bitmask)
+ updateTile()
+ }
+
+ override fun getTileLabel(): String = getString(R.string.tile_network_mode)
+
+ override fun getTileSubtitle(): String {
+ if (!hasFeaturePermission()) return getString(R.string.permission_missing)
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val slotId = getDefaultDataSimSlotFromShell()
+
+ return when (getCurrentNetworkMode(defaultDataSubId, slotId)) {
+ NetworkMode.MODE_NR_ONLY -> getString(R.string.network_mode_nr_only)
+ NetworkMode.MODE_LTE_ONLY -> getString(R.string.network_mode_lte_only)
+ NetworkMode.MODE_5G_PREF -> getString(R.string.network_mode_5g_pref)
+ NetworkMode.MODE_3G_ONLY -> getString(R.string.network_mode_3g_only)
+ NetworkMode.MODE_2G_ONLY -> getString(R.string.network_mode_2g_only)
+ }
+ }
+
+ override fun hasFeaturePermission(): Boolean {
+ return ShellUtils.isAvailable(this) && ShellUtils.hasPermission(this)
+ }
+
+ override fun getTileIcon(): Icon {
+ return Icon.createWithResource(this, R.drawable.rounded_signal_cellular_alt_24)
+ }
+
+ override fun getTileState(): Int {
+ if (!hasFeaturePermission()) return Tile.STATE_INACTIVE
+
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val slotId = getDefaultDataSimSlotFromShell()
+
+ return when (getCurrentNetworkMode(defaultDataSubId, slotId)) {
+ NetworkMode.MODE_NR_ONLY, NetworkMode.MODE_LTE_ONLY -> Tile.STATE_ACTIVE
+ else -> Tile.STATE_INACTIVE
+ }
+ }
+
+ private enum class NetworkMode {
+ MODE_5G_PREF, MODE_NR_ONLY, MODE_LTE_ONLY, MODE_3G_ONLY, MODE_2G_ONLY
+ }
+
+ private fun getDefaultDataSimSlotFromShell(): Int {
+ val defaultDataSubId = getDefaultDataSubIdFromShell()
+ val output = ShellUtils.runCommandWithOutput(this, "dumpsys isub") ?: return 0
+
+ output.lineSequence().forEach { line ->
+ if (line.contains("SubscriptionInfoInternal:")) {
+ val id = parseValue(line, "id=")?.toIntOrNull()
+ val simSlotIndex = parseValue(line, "simSlotIndex=")?.toIntOrNull()
+ if (id == defaultDataSubId && simSlotIndex != null && simSlotIndex >= 0) {
+ return simSlotIndex
+ }
+ }
+ }
+ return 0
+ }
+
+ private fun getDefaultDataSubIdFromShell(): Int {
+ val output = ShellUtils.runCommandWithOutput(this, "settings get global multi_sim_data_call")
+ return output?.trim()?.toIntOrNull() ?: -1
+ }
+
+ private fun parseValue(line: String, key: String): String? {
+ val startIndex = line.indexOf(key)
+ if (startIndex == -1) return null
+ val valStart = startIndex + key.length
+
+ var valEnd = valStart
+ while (valEnd < line.length) {
+ val c = line[valEnd]
+ if (c == ' ' || c == ',' || c == ']') {
+ break
+ }
+ valEnd++
+ }
+ if (valEnd > valStart) {
+ return line.substring(valStart, valEnd).trim()
+ }
+ return null
+ }
+
+ private fun getCurrentNetworkMode(subId: Int, slotId: Int): NetworkMode {
+ var currentAllowed: Long = -1L
+
+ // Try getting via Shizuku binder wrapper reflection first
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ val getMethod = iTelephonyInstance.javaClass.getMethod(
+ "getAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType
+ )
+ currentAllowed = getMethod.invoke(iTelephonyInstance, subId, 0) as Long
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting allowed network types via standard reflection", e)
+ }
+
+ // If reflection failed or binder is null, fallback to shell
+ if (currentAllowed == -1L) {
+ val output = ShellUtils.runCommandWithOutput(this, "cmd phone get-allowed-network-types-for-users -s $slotId")
+ if (output.isNullOrBlank()) {
+ return NetworkMode.MODE_5G_PREF
+ }
+ val hasNR = output.contains("NR", ignoreCase = true)
+ val hasLTE = output.contains("LTE", ignoreCase = true)
+ val has3G = output.contains("UMTS", ignoreCase = true) || output.contains("HSPA", ignoreCase = true) || output.contains("HSDPA", ignoreCase = true)
+ val has2G = output.contains("GSM", ignoreCase = true) || output.contains("GPRS", ignoreCase = true) || output.contains("EDGE", ignoreCase = true)
+
+ return when {
+ hasNR && !hasLTE && !has3G && !has2G -> NetworkMode.MODE_NR_ONLY
+ !hasNR && hasLTE && !has3G && !has2G -> NetworkMode.MODE_LTE_ONLY
+ !hasNR && !hasLTE && has3G && !has2G -> NetworkMode.MODE_3G_ONLY
+ !hasNR && !hasLTE && !has3G && has2G -> NetworkMode.MODE_2G_ONLY
+ else -> NetworkMode.MODE_5G_PREF
+ }
+ }
+
+ val bitmask2G = getBitmaskForTech(16) or getBitmaskForTech(1) or getBitmaskForTech(2) or getBitmaskForTech(4) or getBitmaskForTech(7) or getBitmaskForTech(11)
+ val bitmask3G = getBitmaskForTech(3) or getBitmaskForTech(5) or getBitmaskForTech(6) or getBitmaskForTech(12) or getBitmaskForTech(8) or getBitmaskForTech(9) or getBitmaskForTech(10) or getBitmaskForTech(14) or getBitmaskForTech(15)
+ val bitmask4G = getBitmaskForTech(13) or getBitmaskForTech(19)
+ val bitmask5G = getBitmaskForTech(20)
+
+ val hasNR = (currentAllowed and bitmask5G) != 0L
+ val hasLTE = (currentAllowed and bitmask4G) != 0L
+ val has3G = (currentAllowed and bitmask3G) != 0L
+ val has2G = (currentAllowed and bitmask2G) != 0L
+
+ return when {
+ hasNR && !hasLTE && !has3G && !has2G -> NetworkMode.MODE_NR_ONLY
+ !hasNR && hasLTE && !has3G && !has2G -> NetworkMode.MODE_LTE_ONLY
+ !hasNR && !hasLTE && has3G && !has2G -> NetworkMode.MODE_3G_ONLY
+ !hasNR && !hasLTE && !has3G && has2G -> NetworkMode.MODE_2G_ONLY
+ else -> NetworkMode.MODE_5G_PREF
+ }
+ }
+
+ private fun setAllowedNetworkTypes(subId: Int, slotId: Int, bitmask: Long) {
+ // Try setting via Shizuku binder wrapper reflection first
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try 4-argument signature (Android 14/15/16/17+)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, bitmask, packageName)
+ } catch (e: NoSuchMethodException) {
+ // Fall back to 3-argument signature (Android 11/12/13)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, 0, bitmask)
+ } catch (ex: NoSuchMethodException) {
+ Log.e(TAG, "Both 4-argument and 3-argument setAllowedNetworkTypesForReason methods not found", ex)
+ }
+ }
+
+ if (setMethod != null) {
+ setMethod.invoke(iTelephonyInstance, *args)
+ Log.d(TAG, "Successfully set allowed network types to $bitmask via standard reflection")
+ return
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error setting allowed network types via standard reflection", e)
+ }
+
+ // Fallback for Root/other cases: write via shell
+ val bitmaskString = longToBinaryString20(bitmask)
+ Log.w(TAG, "Shizuku binder reflection failed. Falling back to shell command.")
+ ShellUtils.runCommand(this, "cmd phone set-allowed-network-types-for-users -s $slotId $bitmaskString")
+ }
+
+ private fun getBitmaskForMode(mode: NetworkMode): Long {
+ val bitmask2G = getBitmaskForTech(16) or
+ getBitmaskForTech(1) or
+ getBitmaskForTech(2) or
+ getBitmaskForTech(4) or
+ getBitmaskForTech(7) or
+ getBitmaskForTech(11)
+
+ val bitmask3G = bitmask2G or
+ getBitmaskForTech(3) or
+ getBitmaskForTech(5) or
+ getBitmaskForTech(6) or
+ getBitmaskForTech(12) or
+ getBitmaskForTech(8) or
+ getBitmaskForTech(9) or
+ getBitmaskForTech(10) or
+ getBitmaskForTech(14) or
+ getBitmaskForTech(15)
+
+ val bitmask4G = getBitmaskForTech(13) or getBitmaskForTech(19)
+ val bitmask5G = getBitmaskForTech(20)
+
+ return when (mode) {
+ NetworkMode.MODE_5G_PREF -> bitmask2G or bitmask3G or bitmask4G or bitmask5G
+ NetworkMode.MODE_NR_ONLY -> bitmask5G
+ NetworkMode.MODE_LTE_ONLY -> bitmask4G
+ NetworkMode.MODE_3G_ONLY -> bitmask3G
+ NetworkMode.MODE_2G_ONLY -> bitmask2G
+ }
+ }
+
+ private fun getBitmaskForTech(tech: Int): Long {
+ return if (tech > 0) (1L shl (tech - 1)) else 0L
+ }
+
+ private fun longToBinaryString20(mask: Long): String {
+ val sb = java.lang.StringBuilder()
+ for (i in 20 downTo 1) {
+ val bit = if ((mask and (1L shl (i - 1))) != 0L) '1' else '0'
+ sb.append(bit)
+ }
+ return sb.toString()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/NetworkModeSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/NetworkModeSettingsActivity.kt
new file mode 100644
index 000000000..0f40cba31
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/NetworkModeSettingsActivity.kt
@@ -0,0 +1,594 @@
+package com.sameerasw.essentials.ui.activities
+
+import com.sameerasw.essentials.utils.TelephonyParser
+import com.sameerasw.essentials.utils.SimInfo
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
+import com.sameerasw.essentials.ui.theme.EssentialsTheme
+import com.sameerasw.essentials.utils.HapticUtil
+import com.sameerasw.essentials.utils.ShellUtils
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+
+class NetworkModeSettingsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ val viewModel: com.sameerasw.essentials.viewmodels.MainViewModel =
+ androidx.lifecycle.viewmodel.compose.viewModel()
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ viewModel.check(context)
+ }
+ val isPitchBlackThemeEnabled by viewModel.isPitchBlackThemeEnabled
+ EssentialsTheme(pitchBlackTheme = isPitchBlackThemeEnabled) {
+ NetworkModeSettingsOverlay(onDismiss = { finish() })
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NetworkModeSettingsOverlay(onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ val view = LocalView.current
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ val activeSims = remember { TelephonyParser.parseActiveSims(ShellUtils.runCommandWithOutput(context, "dumpsys isub") ?: "") }
+ val currentSelectedModes = remember { mutableStateMapOf() }
+ var defaultDataSubId by remember { mutableStateOf(getDefaultDataSubIdFromShell(context)) }
+
+ // Load initial network modes
+ LaunchedEffect(Unit) {
+ activeSims.forEach { sim ->
+ currentSelectedModes[sim.id] = getCurrentNetworkMode(context, sim.id, sim.slotIndex)
+ }
+ }
+
+ // Register receiver to listen to SIM changes in real-time
+ DisposableEffect(Unit) {
+ val filter = IntentFilter().apply {
+ addAction("android.intent.action.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED")
+ addAction("android.telephony.action.DEFAULT_SUBSCRIPTION_CHANGED")
+ }
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(c: Context?, intent: Intent?) {
+ defaultDataSubId = getDefaultDataSubIdFromShell(context)
+ activeSims.forEach { sim ->
+ currentSelectedModes[sim.id] = getCurrentNetworkMode(context, sim.id, sim.slotIndex)
+ }
+ }
+ }
+ context.registerReceiver(receiver, filter)
+ onDispose {
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (_: Exception) {}
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_android_cell_dual_4_bar_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(28.dp)
+ )
+ Column {
+ Text(
+ text = "Telephony Controller",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "Dual SIM & Network Controller",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ if (activeSims.isEmpty()) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Text(
+ text = "No active SIM cards found.",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ } else {
+ // Section 1: Default Mobile Data SIM Switch
+ RoundedCardContainer {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Data SIM",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ activeSims.forEach { sim ->
+ val isActive = sim.id == defaultDataSubId
+ Button(
+ onClick = {
+ if (!isActive) {
+ switchDefaultDataSubId(context, sim.id)
+ HapticUtil.performHeavyHaptic(view)
+ }
+ },
+ colors = androidx.compose.material3.ButtonDefaults.buttonColors(
+ containerColor = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(text = sim.displayName)
+ }
+ }
+ }
+ }
+ }
+
+ // Section 2: Network Band Locking per SIM
+ activeSims.forEach { sim ->
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "SIM - ${sim.displayName}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
+ )
+
+ val currentMode = currentSelectedModes[sim.id] ?: NetworkModeOption.MODE_5G_PREF
+ var showDialog by remember { mutableStateOf(false) }
+
+ RoundedCardContainer {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { showDialog = true }
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column {
+ Text(
+ text = "Preferred Network Mode",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "Tap to change configuration",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = when (currentMode) {
+ NetworkModeOption.MODE_5G_PREF -> "5G Preferred"
+ NetworkModeOption.MODE_NR_ONLY -> "5G Only (NR)"
+ NetworkModeOption.MODE_LTE_ONLY -> "4G Only (LTE)"
+ NetworkModeOption.MODE_3G_ONLY -> "3G Only"
+ NetworkModeOption.MODE_2G_ONLY -> "2G Only"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_chevron_right_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+
+ if (showDialog) {
+ NetworkModeSelectionDialog(
+ currentMode = currentMode,
+ onDismiss = { showDialog = false },
+ onSelect = { mode ->
+ currentSelectedModes[sim.id] = mode
+ setAllowedNetworkTypes(context, sim.id, sim.slotIndex, mode)
+ showDialog = false
+ HapticUtil.performUIHaptic(view)
+ }
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+fun NetworkModeSelectionDialog(
+ currentMode: NetworkModeOption,
+ onDismiss: () -> Unit,
+ onSelect: (NetworkModeOption) -> Unit
+) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Select Network Mode") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ NetworkModeDialogItem(
+ label = "5G Preferred (All bands)",
+ isSelected = currentMode == NetworkModeOption.MODE_5G_PREF,
+ onClick = { onSelect(NetworkModeOption.MODE_5G_PREF) }
+ )
+ NetworkModeDialogItem(
+ label = "5G Only (NR Only)",
+ isSelected = currentMode == NetworkModeOption.MODE_NR_ONLY,
+ onClick = { onSelect(NetworkModeOption.MODE_NR_ONLY) }
+ )
+ NetworkModeDialogItem(
+ label = "4G Only (LTE Only)",
+ isSelected = currentMode == NetworkModeOption.MODE_LTE_ONLY,
+ onClick = { onSelect(NetworkModeOption.MODE_LTE_ONLY) }
+ )
+ NetworkModeDialogItem(
+ label = "3G Only",
+ isSelected = currentMode == NetworkModeOption.MODE_3G_ONLY,
+ onClick = { onSelect(NetworkModeOption.MODE_3G_ONLY) }
+ )
+ NetworkModeDialogItem(
+ label = "2G Only",
+ isSelected = currentMode == NetworkModeOption.MODE_2G_ONLY,
+ onClick = { onSelect(NetworkModeOption.MODE_2G_ONLY) }
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shape = RoundedCornerShape(28.dp)
+ )
+}
+
+@Composable
+fun NetworkModeDialogItem(
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selected = isSelected, onClick = onClick)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+private fun switchDefaultDataSubId(context: Context, subId: Int) {
+ try {
+ val binder = SystemServiceHelper.getSystemService("isub")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ISub\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iSubInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ val setDefaultDataSubIdMethod = iSubInstance.javaClass.getMethod(
+ "setDefaultDataSubId",
+ Int::class.javaPrimitiveType
+ )
+ setDefaultDataSubIdMethod.invoke(iSubInstance, subId)
+ Log.d("NetworkModeSettings", "Successfully set default data subscription to $subId via UI")
+
+ // Auto enable mobile data on target subId
+ enableMobileData(context, subId)
+ }
+ } catch (e: Exception) {
+ Log.e("NetworkModeSettings", "Error switching default data SIM via UI", e)
+ }
+}
+
+private fun enableMobileData(context: Context, subId: Int) {
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setDataEnabledMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try setDataEnabledForReason(int subId, int reason, boolean enable, String callingPackage) - Android 14+
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabledForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, true, context.packageName)
+ } catch (e: NoSuchMethodException) {
+ // Try setDataEnabled(int subId, boolean enable) - older versions
+ try {
+ setDataEnabledMethod = iTelephonyInstance.javaClass.getMethod(
+ "setDataEnabled",
+ Int::class.javaPrimitiveType,
+ Boolean::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, true)
+ } catch (ex: NoSuchMethodException) {
+ Log.e("NetworkModeSettings", "Could not find setDataEnabled or setDataEnabledForReason method", ex)
+ }
+ }
+
+ if (setDataEnabledMethod != null) {
+ setDataEnabledMethod.invoke(iTelephonyInstance, *args)
+ Log.d("NetworkModeSettings", "Successfully enabled mobile data for subId=$subId")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("NetworkModeSettings", "Error enabling mobile data for subId=$subId", e)
+ }
+}
+
+
+
+private fun getDefaultDataSubIdFromShell(context: Context): Int {
+ val output = ShellUtils.runCommandWithOutput(context, "settings get global multi_sim_data_call")
+ return output?.trim()?.toIntOrNull() ?: -1
+}
+
+private fun getCurrentNetworkMode(context: Context, subId: Int, slotId: Int): NetworkModeOption {
+ var currentAllowed: Long = -1L
+
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ val getMethod = iTelephonyInstance.javaClass.getMethod(
+ "getAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType
+ )
+ currentAllowed = getMethod.invoke(iTelephonyInstance, subId, 0) as Long
+ }
+ } catch (e: Exception) {
+ Log.e("NetworkModeSettings", "Error getting allowed network types via standard reflection", e)
+ }
+
+ if (currentAllowed == -1L) {
+ val output = ShellUtils.runCommandWithOutput(context, "cmd phone get-allowed-network-types-for-users -s $slotId")
+ if (output.isNullOrBlank()) {
+ return NetworkModeOption.MODE_5G_PREF
+ }
+ val hasNR = output.contains("NR", ignoreCase = true)
+ val hasLTE = output.contains("LTE", ignoreCase = true)
+ val has3G = output.contains("UMTS", ignoreCase = true) || output.contains("HSPA", ignoreCase = true) || output.contains("HSDPA", ignoreCase = true)
+ val has2G = output.contains("GSM", ignoreCase = true) || output.contains("GPRS", ignoreCase = true) || output.contains("EDGE", ignoreCase = true)
+
+ return when {
+ hasNR && !hasLTE && !has3G && !has2G -> NetworkModeOption.MODE_NR_ONLY
+ !hasNR && hasLTE && !has3G && !has2G -> NetworkModeOption.MODE_LTE_ONLY
+ !hasNR && !hasLTE && has3G && !has2G -> NetworkModeOption.MODE_3G_ONLY
+ !hasNR && !hasLTE && !has3G && has2G -> NetworkModeOption.MODE_2G_ONLY
+ else -> NetworkModeOption.MODE_5G_PREF
+ }
+ }
+
+ val bitmask2G = getBitmaskForTech(16) or getBitmaskForTech(1) or getBitmaskForTech(2) or getBitmaskForTech(4) or getBitmaskForTech(7) or getBitmaskForTech(11)
+ val bitmask3G = getBitmaskForTech(3) or getBitmaskForTech(5) or getBitmaskForTech(6) or getBitmaskForTech(12) or getBitmaskForTech(8) or getBitmaskForTech(9) or getBitmaskForTech(10) or getBitmaskForTech(14) or getBitmaskForTech(15)
+ val bitmask4G = getBitmaskForTech(13) or getBitmaskForTech(19)
+ val bitmask5G = getBitmaskForTech(20)
+
+ val hasNR = (currentAllowed and bitmask5G) != 0L
+ val hasLTE = (currentAllowed and bitmask4G) != 0L
+ val has3G = (currentAllowed and bitmask3G) != 0L
+ val has2G = (currentAllowed and bitmask2G) != 0L
+
+ return when {
+ hasNR && !hasLTE && !has3G && !has2G -> NetworkModeOption.MODE_NR_ONLY
+ !hasNR && hasLTE && !has3G && !has2G -> NetworkModeOption.MODE_LTE_ONLY
+ !hasNR && !hasLTE && has3G && !has2G -> NetworkModeOption.MODE_3G_ONLY
+ !hasNR && !hasLTE && !has3G && has2G -> NetworkModeOption.MODE_2G_ONLY
+ else -> NetworkModeOption.MODE_5G_PREF
+ }
+}
+
+private fun setAllowedNetworkTypes(context: Context, subId: Int, slotId: Int, mode: NetworkModeOption) {
+ val bitmask = when (mode) {
+ NetworkModeOption.MODE_5G_PREF -> {
+ val bit2G = getBitmaskForTech(16) or getBitmaskForTech(1) or getBitmaskForTech(2) or getBitmaskForTech(4) or getBitmaskForTech(7) or getBitmaskForTech(11)
+ val bit3G = bit2G or getBitmaskForTech(3) or getBitmaskForTech(5) or getBitmaskForTech(6) or getBitmaskForTech(12) or getBitmaskForTech(8) or getBitmaskForTech(9) or getBitmaskForTech(10) or getBitmaskForTech(14) or getBitmaskForTech(15)
+ val bit4G = getBitmaskForTech(13) or getBitmaskForTech(19)
+ val bit5G = getBitmaskForTech(20)
+ bit2G or bit3G or bit4G or bit5G
+ }
+ NetworkModeOption.MODE_NR_ONLY -> getBitmaskForTech(20)
+ NetworkModeOption.MODE_LTE_ONLY -> getBitmaskForTech(13) or getBitmaskForTech(19)
+ NetworkModeOption.MODE_3G_ONLY -> {
+ val bit2G = getBitmaskForTech(16) or getBitmaskForTech(1) or getBitmaskForTech(2) or getBitmaskForTech(4) or getBitmaskForTech(7) or getBitmaskForTech(11)
+ bit2G or getBitmaskForTech(3) or getBitmaskForTech(5) or getBitmaskForTech(6) or getBitmaskForTech(12) or getBitmaskForTech(8) or getBitmaskForTech(9) or getBitmaskForTech(10) or getBitmaskForTech(14) or getBitmaskForTech(15)
+ }
+ NetworkModeOption.MODE_2G_ONLY -> getBitmaskForTech(16) or getBitmaskForTech(1) or getBitmaskForTech(2) or getBitmaskForTech(4) or getBitmaskForTech(7) or getBitmaskForTech(11)
+ }
+
+ try {
+ val binder = SystemServiceHelper.getSystemService("phone")
+ if (binder != null) {
+ val wrappedBinder = ShizukuBinderWrapper(binder)
+ val stubClass = Class.forName("com.android.internal.telephony.ITelephony\$Stub")
+ val asInterfaceMethod = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ val iTelephonyInstance = asInterfaceMethod.invoke(null, wrappedBinder)
+
+ var setMethod: java.lang.reflect.Method? = null
+ var args = arrayOf()
+
+ // Try 4-argument signature (Android 14/15/16/17+)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType,
+ String::class.java
+ )
+ args = arrayOf(subId, 0, bitmask, context.packageName)
+ } catch (e: NoSuchMethodException) {
+ // Fall back to 3-argument signature (Android 11/12/13)
+ try {
+ setMethod = iTelephonyInstance.javaClass.getMethod(
+ "setAllowedNetworkTypesForReason",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ Long::class.javaPrimitiveType
+ )
+ args = arrayOf(subId, 0, bitmask)
+ } catch (ex: NoSuchMethodException) {
+ Log.e("NetworkModeSettings", "Both 4-argument and 3-argument setAllowedNetworkTypesForReason methods not found", ex)
+ }
+ }
+
+ if (setMethod != null) {
+ setMethod.invoke(iTelephonyInstance, *args)
+ Log.d("NetworkModeSettings", "Successfully set allowed network types to $bitmask via standard reflection")
+ return
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("NetworkModeSettings", "Error setting allowed network types via standard reflection", e)
+ }
+
+ // Fallback to shell
+ val bitmaskString = longToBinaryString20(bitmask)
+ ShellUtils.runCommand(context, "cmd phone set-allowed-network-types-for-users -s $slotId $bitmaskString")
+}
+
+private fun getBitmaskForTech(tech: Int): Long {
+ return if (tech > 0) (1L shl (tech - 1)) else 0L
+}
+
+private fun longToBinaryString20(mask: Long): String {
+ val sb = java.lang.StringBuilder()
+ for (i in 20 downTo 1) {
+ val bit = if ((mask and (1L shl (i - 1))) != 0L) '1' else '0'
+ sb.append(bit)
+ }
+ return sb.toString()
+}
+
+
+
+enum class NetworkModeOption {
+ MODE_5G_PREF, MODE_NR_ONLY, MODE_LTE_ONLY, MODE_3G_ONLY, MODE_2G_ONLY
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt
index 93265925d..2cab6f998 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt
@@ -51,6 +51,17 @@ class QSPreferencesActivity : ComponentActivity() {
return
}
+ if (componentName.className == "com.sameerasw.essentials.services.tiles.DataSimTileService" ||
+ componentName.className == "com.sameerasw.essentials.services.tiles.NetworkModeTileService"
+ ) {
+ val intent = Intent(this, NetworkModeSettingsActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ startActivity(intent)
+ finish()
+ return
+ }
+
if (componentName.className == "com.sameerasw.essentials.services.tiles.AdaptiveBrightnessTileService") {
val displayIntent = Intent(Settings.ACTION_DISPLAY_SETTINGS).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -81,6 +92,8 @@ class QSPreferencesActivity : ComponentActivity() {
"com.sameerasw.essentials.services.tiles.FlashlightPulseTileService" -> "Notification lighting"
"com.sameerasw.essentials.services.tiles.StayAwakeTileService" -> "Quick settings tiles"
"com.sameerasw.essentials.services.tiles.NfcTileService" -> "NFC"
+ "com.sameerasw.essentials.services.tiles.DataSimTileService" -> "Quick settings tiles"
+ "com.sameerasw.essentials.services.tiles.NetworkModeTileService" -> "Quick settings tiles"
"com.sameerasw.essentials.services.tiles.AdaptiveBrightnessTileService" -> "Quick settings tiles"
"com.sameerasw.essentials.services.tiles.RefreshRateTileService" -> "Screen refresh rate"
"com.sameerasw.essentials.services.tiles.MapsPowerSavingTileService" -> "Maps power saving mode"
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt
index 53a3b9b99..3d544841f 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt
@@ -62,6 +62,8 @@ import com.sameerasw.essentials.services.tiles.FlashlightTileService
import com.sameerasw.essentials.services.tiles.MapsPowerSavingTileService
import com.sameerasw.essentials.services.tiles.MonoAudioTileService
import com.sameerasw.essentials.services.tiles.NfcTileService
+import com.sameerasw.essentials.services.tiles.DataSimTileService
+import com.sameerasw.essentials.services.tiles.NetworkModeTileService
import com.sameerasw.essentials.services.tiles.NotificationLightingTileService
import com.sameerasw.essentials.services.tiles.PrivateDnsTileService
import com.sameerasw.essentials.services.tiles.PrivateNotificationsTileService
@@ -262,6 +264,24 @@ fun QuickSettingsTilesSettingsUI(
R.string.about_desc_nfc,
R.string.cat_connectivity
),
+ QSTileInfo(
+ R.string.tile_data_sim,
+ R.drawable.rounded_android_cell_dual_4_bar_24,
+ DataSimTileService::class.java,
+ if (ShellUtils.isRootEnabled(context)) listOf("ROOT")
+ else listOf("SHIZUKU"),
+ R.string.about_desc_data_sim,
+ R.string.cat_connectivity
+ ),
+ QSTileInfo(
+ R.string.tile_network_mode,
+ R.drawable.rounded_signal_cellular_alt_24,
+ NetworkModeTileService::class.java,
+ if (ShellUtils.isRootEnabled(context)) listOf("ROOT")
+ else listOf("SHIZUKU"),
+ R.string.about_desc_network_mode,
+ R.string.cat_connectivity
+ ),
QSTileInfo(
R.string.tile_adaptive_brightness,
R.drawable.rounded_brightness_auto_24,
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/TelephonyParser.kt b/app/src/main/java/com/sameerasw/essentials/utils/TelephonyParser.kt
new file mode 100644
index 000000000..14cc04e28
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/utils/TelephonyParser.kt
@@ -0,0 +1,54 @@
+package com.sameerasw.essentials.utils
+
+data class SimInfo(
+ val id: Int,
+ val slotIndex: Int,
+ val displayName: String
+)
+
+object TelephonyParser {
+ fun parseActiveSims(output: String): List {
+ val list = mutableListOf()
+ var inActiveList = false
+ output.lineSequence().forEach { line ->
+ val trimmed = line.trim()
+ if (line.contains("Active subscriptions:")) {
+ inActiveList = true
+ } else if (inActiveList && (trimmed.startsWith("Embedded subscriptions:") || trimmed.startsWith("All Subscription Info List:") || trimmed.startsWith("getAvailableSubscriptionInfoList:"))) {
+ inActiveList = false
+ }
+
+ if (inActiveList && line.contains("SubscriptionInfoInternal:")) {
+ val id = parseValue(line, "id=")?.toIntOrNull()
+ val simSlotIndex = parseValue(line, "simSlotIndex=")?.toIntOrNull()
+ val displayName = parseValue(line, "displayName=") ?: "SIM"
+
+ if (id != null && simSlotIndex != null && simSlotIndex >= 0) {
+ if (list.none { it.id == id }) {
+ list.add(SimInfo(id, simSlotIndex, displayName))
+ }
+ }
+ }
+ }
+ return list.sortedBy { it.slotIndex }
+ }
+
+ private fun parseValue(line: String, key: String): String? {
+ val startIndex = line.indexOf(key)
+ if (startIndex == -1) return null
+ val valStart = startIndex + key.length
+
+ var valEnd = valStart
+ while (valEnd < line.length) {
+ val c = line[valEnd]
+ if (c == ' ' || c == ',' || c == ']') {
+ break
+ }
+ valEnd++
+ }
+ if (valEnd > valStart) {
+ return line.substring(valStart, valEnd).trim()
+ }
+ return null
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 67f6aa73a..d3a228e5d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -256,6 +256,13 @@
Refresh Rate
Cycle between the system default and preset refresh rates directly from a Quick Settings tile using Shizuku.
NFC
+ Data SIM
+ Network Mode
+ 5G Preferred
+ NR Only
+ LTE Only
+ 3G Only
+ 2G Only
Private DNS
Auto
Off
@@ -848,6 +855,8 @@
Toggle the flashlight.\n\nA Long pressing opens the controls for intensity adjustment which might need hardware implementation which some devices may lack.
Keep the screen awake while charging.\n\nPrevents the screen from sleeping as long as the device is connected to a power source which is suitable for developers during debugging.
Toggle NFC.\n\nQuickly enable or disable Near Field Communication for payments and pairing.
+ Switch default data SIM.\n\nQuickly toggle the active mobile data SIM subscription on dual-SIM devices using Shizuku or Root.
+ Switch preferred network mode.\n\nCycle through 5G, 4G, 3G, and 2G preferred network modes using Shizuku or Root.
Toggle adaptive brightness.\n\nEnable or disable automatic screen brightness adjustment based on ambient light.
Toggle Private DNS.\n\nCycle through Off, Automatic, and Private DNS provider modes.
Toggle USB Debugging.\n\nEnable or disable ADB debugging access directly from the quick settings.
diff --git a/app/src/test/java/com/sameerasw/essentials/utils/TelephonyParserTest.kt b/app/src/test/java/com/sameerasw/essentials/utils/TelephonyParserTest.kt
new file mode 100644
index 000000000..d3525929f
--- /dev/null
+++ b/app/src/test/java/com/sameerasw/essentials/utils/TelephonyParserTest.kt
@@ -0,0 +1,60 @@
+package com.sameerasw.essentials.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TelephonyParserTest {
+
+ @Test
+ fun parseActiveSims_filtersDisabledESIMsAndTraces() {
+ val sampleDumpsysIsub = """
+Active modem count=2
+activeDataSubId=5
+Active subscriptions:
+ [SubscriptionInfoInternal: id=3 iccId=899186104[****] simSlotIndex=1 portIndex=0 isEmbedded=1 carrierId=2018 displayName=Jio carrierName=Jio True5G]
+ [SubscriptionInfoInternal: id=4 iccId=899100090[****] simSlotIndex=-1 portIndex=-1 isEmbedded=0 carrierId=1961 displayName=Airtel carrierName=airtel]
+ [SubscriptionInfoInternal: id=5 iccId=899145227[****] simSlotIndex=0 portIndex=1 isEmbedded=1 carrierId=1961 displayName=Airtel carrierName=airtel Wi-Fi Calling]
+ [SubscriptionInfoInternal: id=6 iccId=899145227[****] simSlotIndex=-1 portIndex=-1 isEmbedded=0 carrierId=1961 displayName=CARD 5 carrierName=]
+
+Embedded subscriptions: [1, 3, 5]
+All Subscription Info List:
+ [SubscriptionInfoInternal: id=3 iccId=899186104[****] simSlotIndex=1 portIndex=0 isEmbedded=1 carrierId=2018 displayName=Jio carrierName=Jio True5G]
+ [SubscriptionInfoInternal: id=4 iccId=899100090[****] simSlotIndex=-1 portIndex=-1 isEmbedded=0 carrierId=1961 displayName=Airtel carrierName=airtel]
+ [SubscriptionInfoInternal: id=5 iccId=899145227[****] simSlotIndex=0 portIndex=1 isEmbedded=1 carrierId=1961 displayName=Airtel carrierName=airtel Wi-Fi Calling]
+ [SubscriptionInfoInternal: id=6 iccId=899145227[****] simSlotIndex=-1 portIndex=-1 isEmbedded=0 carrierId=1961 displayName=CARD 5 carrierName=]
+ """.trimIndent()
+
+ val parsedSims = TelephonyParser.parseActiveSims(sampleDumpsysIsub)
+
+ // There should be exactly 2 active SIMs parsed
+ assertEquals(2, parsedSims.size)
+
+ // The first parsed SIM should be Airtel (slot 0)
+ assertEquals(5, parsedSims[0].id)
+ assertEquals(0, parsedSims[0].slotIndex)
+ assertEquals("Airtel", parsedSims[0].displayName)
+
+ // The second parsed SIM should be Jio (slot 1)
+ assertEquals(3, parsedSims[1].id)
+ assertEquals(1, parsedSims[1].slotIndex)
+ assertEquals("Jio", parsedSims[1].displayName)
+ }
+
+ @Test
+ fun parseActiveSims_returnsEmptyOnEmptyOutput() {
+ val parsedSims = TelephonyParser.parseActiveSims("")
+ assertEquals(0, parsedSims.size)
+ }
+
+ @Test
+ fun parseActiveSims_ignoresNonActiveSectionSubscriptions() {
+ val sampleDumpsysIsub = """
+All Subscription Info List:
+ [SubscriptionInfoInternal: id=3 iccId=899186104[****] simSlotIndex=1 portIndex=0 isEmbedded=1 carrierId=2018 displayName=Jio carrierName=Jio True5G]
+ [SubscriptionInfoInternal: id=5 iccId=899145227[****] simSlotIndex=0 portIndex=1 isEmbedded=1 carrierId=1961 displayName=Airtel carrierName=airtel Wi-Fi Calling]
+ """.trimIndent()
+
+ val parsedSims = TelephonyParser.parseActiveSims(sampleDumpsysIsub)
+ assertEquals(0, parsedSims.size) // No Active subscriptions header, so it should return empty list
+ }
+}