diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 22a5513..6726641 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -10,8 +10,8 @@ android {
applicationId = "com.dhangofa.networktoggle"
minSdk = 24
targetSdk = 36
- versionCode = 32
- versionName = "1.0.32"
+ versionCode = 65
+ versionName = "1.0.65"
}
// Suggested by IzzyOnDroid
dependenciesInfo {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f4291a9..70dc52b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
{
+ new Thread(() -> {
+ NetworkMode currentMode = appPreferences.getCachedNetworkMode();
+
+ // If cache is wiped (e.g. from SIM switch), but execution is allowed, read it directly once
+ if (currentMode == NetworkMode.UNKNOWN && appPreferences.getExecutionMode() != ExecutionMode.NONE) {
+ currentMode = new com.dhangofa.networktoggle.telephony.NetworkModeReader(this, appPreferences, simResolver).readCurrentMode();
+ }
+
+ if (currentMode != NetworkMode.UNKNOWN && !newCycle.contains(currentMode)) {
+ NetworkMode fallbackMode = newCycle.get(0);
+ // Attempt to sync the network state silently
+ modeController.apply(fallbackMode, appPreferences.getExecutionMode());
+ appPreferences.setCachedNetworkMode(fallbackMode);
+ } else if (currentMode != NetworkMode.UNKNOWN) {
+ // It's in the cycle, ensure it is cached so UI shows the actual active state
+ appPreferences.setCachedNetworkMode(currentMode);
+ }
+ }).start();
+ });
+
tileCycleUiController.initialize();
registerShizukuListeners();
loadSavedExecutionMode();
loadSavedTargetSimMode();
updateAutoSimWarning();
bindSelectionListeners();
+ updateSeparatorVisibility();
}
private void configureStatusBar() {
@@ -118,7 +171,7 @@ private void configureStatusBar() {
boolean isNight = (getResources().getConfiguration().uiMode
& Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
- getWindow().setStatusBarColor(getColor(R.color.surface_background));
+ getWindow().setStatusBarColor(getColor(R.color.card_surface));
getWindow().getDecorView().setSystemUiVisibility(
isNight ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
@@ -136,11 +189,47 @@ private void bindViews() {
autoSimWarningText = findViewById(R.id.autoSimWarningText);
githubLink = findViewById(R.id.githubLink);
telegramLink = findViewById(R.id.telegramLink);
+ separatorRootShizuku = findViewById(R.id.separatorRootShizuku);
+ separatorAutoSim1 = findViewById(R.id.separatorAutoSim1);
+ separatorSim1Sim2 = findViewById(R.id.separatorSim1Sim2);
+
+ errorBannerContainer = findViewById(R.id.cardErrorBanner);
+ if (errorBannerContainer != null) {
+ errorBannerContainer.setOnClickListener(v -> showDiagnosticDialog());
+ }
+
+
}
private void bindLinks() {
githubLink.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle"));
- telegramLink.setOnClickListener(v -> openUrl("https://t.me/dhangofa"));
+ telegramLink.setOnClickListener(v -> openUrl("https://t.me/dhangofas_projects_chat"));
+ }
+
+ private void updateSeparatorVisibility() {
+ // Execution Mode Separator
+ int modeId = radioGroup.getCheckedRadioButtonId();
+ if (modeId == -1) {
+ separatorRootShizuku.setVisibility(View.VISIBLE);
+ } else {
+ separatorRootShizuku.setVisibility(View.INVISIBLE);
+ }
+
+ // Target SIM Separators
+ int simId = targetSimRadioGroup.getCheckedRadioButtonId();
+ if (simId == -1) {
+ separatorAutoSim1.setVisibility(View.VISIBLE);
+ separatorSim1Sim2.setVisibility(View.VISIBLE);
+ } else if (simId == R.id.radioSimAuto) {
+ separatorAutoSim1.setVisibility(View.INVISIBLE);
+ separatorSim1Sim2.setVisibility(View.VISIBLE);
+ } else if (simId == R.id.radioSim1) {
+ separatorAutoSim1.setVisibility(View.INVISIBLE);
+ separatorSim1Sim2.setVisibility(View.INVISIBLE);
+ } else if (simId == R.id.radioSim2) {
+ separatorAutoSim1.setVisibility(View.VISIBLE);
+ separatorSim1Sim2.setVisibility(View.INVISIBLE);
+ }
}
private void registerShizukuListeners() {
@@ -163,8 +252,11 @@ private void loadSavedExecutionMode() {
}
}
+ private boolean updatingSimUi = false;
+
private void bindSelectionListeners() {
radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ updateSeparatorVisibility();
if (checkedId == R.id.radioRoot) {
appPreferences.onExecutionModeChanged(ExecutionMode.ROOT);
checkRootPermission();
@@ -174,12 +266,51 @@ private void bindSelectionListeners() {
}
});
+ android.view.View.OnTouchListener lockTouch = (v, event) -> {
+ if (!isUIAuthorized && event.getAction() == android.view.MotionEvent.ACTION_DOWN) {
+ android.widget.Toast.makeText(MainActivity.this, "Please authorize Root or Shizuku to configure toggles.", android.widget.Toast.LENGTH_SHORT).show();
+ return true;
+ }
+ return false;
+ };
+ radioSimAuto.setOnTouchListener(lockTouch);
+ radioSim1.setOnTouchListener(lockTouch);
+ radioSim2.setOnTouchListener(lockTouch);
+
targetSimRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (updatingSimUi) return;
TargetSim target = TargetSim.AUTO;
if (checkedId == R.id.radioSim1) target = TargetSim.SIM_1;
else if (checkedId == R.id.radioSim2) target = TargetSim.SIM_2;
+
+ // Validate slot immediately
+ if (target != TargetSim.AUTO && checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
+ android.telephony.SubscriptionManager sm = getSystemService(android.telephony.SubscriptionManager.class);
+ if (sm != null) {
+ boolean found = false;
+ java.util.List infos = sm.getActiveSubscriptionInfoList();
+ if (infos != null) {
+ for (android.telephony.SubscriptionInfo info : infos) {
+ if (info.getSimSlotIndex() == target.getManualSlotIndex()) {
+ found = true;
+ break;
+ }
+ }
+ }
+ if (!found) {
+ Toast.makeText(MainActivity.this, "No SIM card found in slot " + (target.getManualSlotIndex() + 1), Toast.LENGTH_SHORT).show();
+ updatingSimUi = true;
+ radioSimAuto.setChecked(true);
+ updatingSimUi = false;
+ target = TargetSim.AUTO;
+ }
+ }
+ }
+
+ updateSeparatorVisibility();
appPreferences.onTargetSimChanged(target);
updateAutoSimWarning();
+ updateCapabilities();
});
}
@@ -187,11 +318,125 @@ private void bindSelectionListeners() {
protected void onResume() {
super.onResume();
if (appPreferences != null) updateAutoSimWarning();
+ checkAndRequestPermission();
+ updateErrorBanner();
+ updateCapabilities();
+ }
+
+ private void checkAndRequestPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ boolean granted = checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
+ if (!granted) {
+ boolean shouldShowRationale = shouldShowRequestPermissionRationale(android.Manifest.permission.READ_PHONE_STATE);
+ if (shouldShowRationale) {
+ showPermissionBottomSheet();
+ } else {
+ // Automatically prompt on first open if not asked yet
+ requestPermissions(new String[]{android.Manifest.permission.READ_PHONE_STATE}, REQ_CODE_PHONE_STATE);
+ }
+ } else if (permissionDialog != null && permissionDialog.isShowing()) {
+ permissionDialog.dismiss();
+ }
+ }
+ }
+
+ private void showPermissionBottomSheet() {
+ if (permissionDialog == null) {
+ permissionDialog = new Dialog(this, R.style.TransparentBottomSheetStyle);
+ View view = getLayoutInflater().inflate(R.layout.bottom_sheet_permission, null);
+
+ view.findViewById(R.id.btnDismissPermission).setOnClickListener(v -> permissionDialog.dismiss());
+ view.findViewById(R.id.btnGrantPermission).setOnClickListener(v -> {
+ permissionDialog.dismiss();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ requestPermissions(new String[]{android.Manifest.permission.READ_PHONE_STATE}, REQ_CODE_PHONE_STATE);
+ }
+ });
+
+ permissionDialog.setContentView(view);
+ Window window = permissionDialog.getWindow();
+ if (window != null) {
+ window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ window.setGravity(Gravity.BOTTOM);
+ window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ }
+ }
+
+ if (!permissionDialog.isShowing() && !activityDestroyed) {
+ permissionDialog.show();
+ }
+ }
+
+ private void updateErrorBanner() {
+ if (errorBannerContainer != null && appPreferences != null) {
+ DiagnosticError error = appPreferences.getLastError();
+ errorBannerContainer.setVisibility(error != null ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ private void showDiagnosticDialog() {
+ if (isFinishing() || activityDestroyed) return;
+
+ if (diagnosticDialog != null && diagnosticDialog.isShowing()) {
+ diagnosticDialog.dismiss(); appPreferences.clearLastError(); updateErrorBanner();
+ }
+
+ diagnosticDialog = new Dialog(this);
+ diagnosticDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
+ diagnosticDialog.setContentView(R.layout.dialog_diagnostic);
+ diagnosticDialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ diagnosticDialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ TextView reportText = diagnosticDialog.findViewById(R.id.diagnosticReportText);
+ if (reportText != null) {
+ String report = DiagnosticReporter.generateReport(appPreferences, new SimResolver(this, appPreferences));
+ reportText.setText(report);
+ }
+
+ View btnClose = diagnosticDialog.findViewById(R.id.btnCloseDiagnostic);
+ if (btnClose != null) btnClose.setOnClickListener(v -> { diagnosticDialog.dismiss(); appPreferences.clearLastError(); updateErrorBanner(); });
+
+ View btnCopy = diagnosticDialog.findViewById(R.id.btnCopyDiagnostic);
+ if (btnCopy != null) {
+ btnCopy.setOnClickListener(v -> {
+ ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("Diagnostic Report", reportText.getText());
+ clipboard.setPrimaryClip(clip);
+ Toast.makeText(this, "Report copied to clipboard", Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ diagnosticDialog.show();
+ }
+
+ private void updateCapabilities() {
+ new Thread(() -> {
+ AppPreferences.NetworkCapabilities caps = capabilityResolver.getCapabilities(appPreferences.getExecutionMode());
+ runOnUiThread(() -> {
+ if (!activityDestroyed && tileCycleUiController != null) {
+ tileCycleUiController.applyCapabilities(caps);
+ }
+ });
+ }).start();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == REQ_CODE_PHONE_STATE) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (permissionDialog != null && permissionDialog.isShowing()) permissionDialog.dismiss();
+ updateCapabilities(); // Fetch capabilities immediately upon grant
+ } else {
+ showPermissionBottomSheet();
+ }
+ }
}
@Override
protected void onDestroy() {
activityDestroyed = true;
+ if (appPreferences != null) appPreferences.unregisterListener(this);
Shizuku.removeBinderReceivedListener(binderReceivedListener);
Shizuku.removeBinderDeadListener(binderDeadListener);
Shizuku.removeRequestPermissionResultListener(permissionResultListener);
@@ -207,6 +452,13 @@ protected void onDestroy() {
super.onDestroy();
}
+ @Override
+ public void onSharedPreferenceChanged(android.content.SharedPreferences sharedPreferences, String key) {
+ if ("last_error_cmd".equals(key)) {
+ runOnUiThread(() -> updateErrorBanner());
+ }
+ }
+
private void checkRootPermission() {
setStatus("Checking root permission...", 0xFFFFB300);
rootCheckThread = new Thread(() -> {
@@ -227,8 +479,14 @@ private void checkRootPermission() {
runOnUiThread(() -> {
if (activityDestroyed || appPreferences == null
|| appPreferences.getExecutionMode() != ExecutionMode.ROOT) return;
- if (finalGranted) setStatus("Root mode active & authorized!", 0xFF1B873F);
- else setStatus("Root permission denied or unavailable.", 0xFFFF5555);
+ if (finalGranted) {
+ setStatus("Root mode active & authorized!", 0xFF1B873F);
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_NONE);
+ } else {
+ setStatus("Root permission denied or unavailable.", 0xFFFF5555);
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_ROOT);
+ }
+ android.service.quicksettings.TileService.requestListeningState(MainActivity.this, new android.content.ComponentName(MainActivity.this, NetworkTileService.class));
});
});
rootCheckThread.start();
@@ -239,19 +497,35 @@ private void checkShizukuPermission(boolean requestIfNeeded) {
try {
if (!Shizuku.pingBinder()) {
setStatus("Shizuku is not running.", 0xFFFF5555);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_SHIZUKU);
+ android.service.quicksettings.TileService.requestListeningState(MainActivity.this, new android.content.ComponentName(MainActivity.this, NetworkTileService.class));
+ }
return;
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
setStatus("Shizuku mode active & authorized!", 0xFF1B873F);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_NONE);
+ android.service.quicksettings.TileService.requestListeningState(MainActivity.this, new android.content.ComponentName(MainActivity.this, NetworkTileService.class));
+ }
return;
}
setStatus("Shizuku permission not granted.", 0xFFFFB300);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_SHIZUKU);
+ android.service.quicksettings.TileService.requestListeningState(MainActivity.this, new android.content.ComponentName(MainActivity.this, NetworkTileService.class));
+ }
if (requestIfNeeded) {
setStatus("Requesting Shizuku permission...", 0xFFFFB300);
Shizuku.requestPermission(0);
}
} catch (Exception e) {
setStatus("Shizuku check failed.", 0xFFFF5555);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(com.dhangofa.networktoggle.config.AppPreferences.TILE_ERROR_SHIZUKU);
+ android.service.quicksettings.TileService.requestListeningState(MainActivity.this, new android.content.ComponentName(MainActivity.this, NetworkTileService.class));
+ }
}
}
@@ -272,6 +546,32 @@ private void setStatus(String text, int color) {
R.drawable.shape_pill_badge_bg
);
}
+ updateAuthorizationUI(color == 0xFF1B873F);
+ }
+
+ private boolean isUIAuthorized = true;
+ private void updateAuthorizationUI(boolean authorized) {
+ if (isUIAuthorized == authorized) return;
+ isUIAuthorized = authorized;
+
+ float alpha = authorized ? 1.0f : 0.4f;
+ View targetSimCard = findViewById(R.id.targetSimRadioGroup);
+ if (targetSimCard != null) targetSimCard.setAlpha(alpha);
+ View targetSimHeader = findViewById(R.id.targetSimHeaderContainer);
+ if (targetSimHeader != null) targetSimHeader.setAlpha(alpha);
+ View autoSimWarningText = findViewById(R.id.autoSimWarningText);
+ if (autoSimWarningText != null) autoSimWarningText.setAlpha(alpha);
+
+ if (tileCycleUiController != null) {
+ tileCycleUiController.setAuthorized(authorized);
+ }
+
+ if (authorized && appPreferences != null) {
+ appPreferences.clearDeviceCapabilities();
+ appPreferences.invalidateSlotCache(0);
+ appPreferences.invalidateSlotCache(1);
+ updateCapabilities();
+ }
}
private String getAppVersionName() {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java b/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
index 5d62579..6579114 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
@@ -1,4 +1,5 @@
package com.dhangofa.networktoggle;
+import android.os.Build;
import android.graphics.Bitmap;
import android.graphics.Canvas;
@@ -10,6 +11,10 @@
import android.os.Looper;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
+import android.content.Intent;
+import android.app.PendingIntent;
+import rikka.shizuku.Shizuku;
+import android.content.pm.PackageManager;
import android.widget.Toast;
import com.dhangofa.networktoggle.config.AppPreferences;
@@ -35,6 +40,8 @@ public class NetworkTileService extends TileService {
private static Icon icon5g;
private static Icon iconP5g;
private static Icon iconP4g;
+ private static Icon iconP3g;
+ private static Icon icon2g;
private static Icon iconUnknown;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -42,22 +49,41 @@ public class NetworkTileService extends TileService {
private AppPreferences appPreferences;
private NetworkModeReader networkModeReader;
private NetworkModeController networkModeController;
- private TileCycleManager tileCycleManager;
+ private TileCycleManager tileCycleManager;
+ private com.dhangofa.networktoggle.telephony.SimResolver simResolver;
@Override
public void onCreate() {
super.onCreate();
+ // Init dependencies
appPreferences = new AppPreferences(this);
- tileCycleManager = new TileCycleManager(appPreferences);
- SimResolver simResolver = new SimResolver(appPreferences);
- networkModeReader = new NetworkModeReader(appPreferences, simResolver);
+ tileCycleManager = new TileCycleManager(appPreferences);
+ simResolver = new com.dhangofa.networktoggle.telephony.SimResolver(this, appPreferences);
+ networkModeReader = new com.dhangofa.networktoggle.telephony.NetworkModeReader(this, appPreferences, simResolver);
networkModeController = new NetworkModeController(simResolver);
}
@Override
public void onStartListening() {
super.onStartListening();
+
+ // Passive Shizuku Check
+ if (appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ boolean isShizukuOk = false;
+ try {
+ isShizukuOk = rikka.shizuku.Shizuku.pingBinder() && rikka.shizuku.Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED;
+ } catch (Throwable t) {
+ isShizukuOk = false;
+ }
+
+ int currentError = appPreferences.getTileErrorState();
+ if (!isShizukuOk && currentError != AppPreferences.TILE_ERROR_SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ } else if (isShizukuOk && currentError == AppPreferences.TILE_ERROR_SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ }
+ }
NetworkMode cachedMode = appPreferences.getCachedNetworkMode();
updateTileUI(cachedMode);
@@ -67,25 +93,25 @@ public void onStartListening() {
return;
}
- EXECUTOR.execute(() -> {
- NetworkMode realMode = networkModeReader.readCurrentMode();
+ EXECUTOR.execute(() -> {
+ NetworkMode realMode = networkModeReader.readCurrentMode();
- mainHandler.post(() -> {
- /*
- * A tile click or another operation may have updated the state
- * while this asynchronous readback was running.
- */
- if (appPreferences.getCachedNetworkMode()
- != NetworkMode.UNKNOWN) {
- return;
- }
+ mainHandler.post(() -> {
+ /*
+ * A tile click or another operation may have updated the state
+ * while this asynchronous readback was running.
+ */
+ if (appPreferences.getCachedNetworkMode()
+ != NetworkMode.UNKNOWN) {
+ return;
+ }
- if (realMode != NetworkMode.UNKNOWN) {
- appPreferences.setCachedNetworkMode(realMode);
- updateTileUI(realMode);
- }
+ if (realMode != NetworkMode.UNKNOWN) {
+ appPreferences.setCachedNetworkMode(realMode);
+ updateTileUI(realMode);
+ }
+ });
});
- });
}
@Override
@@ -109,27 +135,70 @@ public void onClick() {
updateTileSwitchingUI();
EXECUTOR.execute(() -> {
+ int slotIndex = simResolver.resolveTargetSlotIndex(executionMode);
+ if (!simResolver.isValidSlotIndex(slotIndex)) {
+ mainHandler.post(() -> {
+ Toast.makeText(getApplicationContext(), "No SIM card found in target slot.", Toast.LENGTH_SHORT).show();
+ appPreferences.onTargetSimChanged(com.dhangofa.networktoggle.model.TargetSim.AUTO);
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ IS_SWITCHING.set(false);
+ });
+ return;
+ }
+
CommandResult result = networkModeController.apply(
nextMode,
executionMode
);
- mainHandler.post(() -> {
- try {
- if (result.isSuccess()) {
- appPreferences.setCachedNetworkMode(nextMode);
- appPreferences.setAutoSimError(false);
- updateTileUI(nextMode);
- } else {
- updateTileUI(currentMode);
- if (appPreferences.hasAutoSimError()) {
- showAutoSimErrorToast();
+ if (result.isSuccess()) {
+ appPreferences.setCachedNetworkMode(nextMode);
+ appPreferences.setAutoSimError(false);
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ mainHandler.post(() -> {
+ updateTileUI(nextMode);
+ IS_SWITCHING.set(false);
+ });
+ } else {
+ // Command failed! Check for permission failures first.
+ boolean isAuthError = false;
+ if (executionMode == ExecutionMode.SHIZUKU) {
+ try {
+ if (!Shizuku.pingBinder() || Shizuku.checkSelfPermission() != PackageManager.PERMISSION_GRANTED) {
+ isAuthError = true;
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
}
+ } catch (Throwable t) {
+ isAuthError = true;
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ }
+ } else if (executionMode == ExecutionMode.ROOT) {
+ try {
+ Process p = Runtime.getRuntime().exec(new String[]{"su", "-c", "true"});
+ int exitCode = p.waitFor();
+ if (exitCode != 0) {
+ isAuthError = true;
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_ROOT);
+ }
+ } catch (Exception e) {
+ isAuthError = true;
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_ROOT);
}
- } finally {
- IS_SWITCHING.set(false);
}
- });
+
+ if (!isAuthError) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ appPreferences.setLastError(result.getCommand(), result.getStderr());
+ }
+
+ mainHandler.post(() -> {
+ updateTileUI(currentMode);
+ if (appPreferences.hasAutoSimError()) {
+ showAutoSimErrorToast();
+ }
+ IS_SWITCHING.set(false);
+ });
+ }
});
}
@@ -177,6 +246,12 @@ private Icon getCachedIcon(String text) {
case "P4G":
if (iconP4g == null) iconP4g = createTextOnlyIcon("P4G");
return iconP4g;
+ case "P3G":
+ if (iconP3g == null) iconP3g = createTextOnlyIcon("P3G");
+ return iconP3g;
+ case "2G":
+ if (icon2g == null) icon2g = createTextOnlyIcon("2G");
+ return icon2g;
default:
if (iconUnknown == null) {
iconUnknown = createTextOnlyIcon("?");
@@ -202,6 +277,27 @@ private void updateTileUI(NetworkMode mode) {
return;
}
+ int errorState = appPreferences.getTileErrorState();
+ if (errorState == AppPreferences.TILE_ERROR_SHIZUKU) {
+ tile.setState(Tile.STATE_UNAVAILABLE);
+ tile.setLabel("Shizuku Unavailable");
+ tile.setIcon(getCachedIcon("?"));
+ tile.updateTile();
+ return;
+ } else if (errorState == AppPreferences.TILE_ERROR_ROOT) {
+ tile.setState(Tile.STATE_UNAVAILABLE);
+ tile.setLabel("Root Unavailable");
+ tile.setIcon(getCachedIcon("?"));
+ tile.updateTile();
+ return;
+ } else if (errorState == AppPreferences.TILE_ERROR_CMD) {
+ tile.setState(Tile.STATE_INACTIVE);
+ tile.setLabel("Error Check App");
+ tile.setIcon(getCachedIcon("?"));
+ tile.updateTile();
+ return;
+ }
+
if (mode == NetworkMode.UNKNOWN) {
if (appPreferences.getExecutionMode() == ExecutionMode.NONE) {
tile.setState(Tile.STATE_UNAVAILABLE);
@@ -223,6 +319,8 @@ private void updateTileUI(NetworkMode mode) {
tile.updateTile();
}
+
+
private void showAutoSimErrorToast() {
Toast.makeText(
this,
diff --git a/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java b/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java
index de5ac8f..fdc95fb 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java
@@ -17,6 +17,21 @@ public final class AppPreferences {
private static final String KEY_NETWORK_STATE = "net_state";
private static final String KEY_AUTO_SIM_ERROR = "auto_sim_error";
private static final String KEY_TILE_CYCLE_MODES = "tile_cycle_modes";
+
+ private static final String KEY_LAST_ERROR_CMD = "last_error_cmd";
+ private static final String KEY_LAST_ERROR_STDERR = "last_error_stderr";
+ private static final String KEY_LAST_ERROR_TIMESTAMP = "last_error_time";
+
+ // Keys for capabilities caching
+ private static final String KEY_DEVICE_CAPS_PREFIX = "device_cap_";
+ private static final String KEY_SLOT_SUBID_PREFIX = "slot_subid_";
+ private static final String KEY_SLOT_CAPS_PREFIX = "slot_cap_";
+
+ public static final int TILE_ERROR_NONE = 0;
+ public static final int TILE_ERROR_SHIZUKU = 1;
+ public static final int TILE_ERROR_ROOT = 2;
+ public static final int TILE_ERROR_CMD = 3;
+ private static final String KEY_TILE_ERROR = "tile_error_state";
private final SharedPreferences preferences;
@@ -25,6 +40,22 @@ public AppPreferences(Context context) {
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
+ public int getTileErrorState() {
+ return preferences.getInt(KEY_TILE_ERROR, TILE_ERROR_NONE);
+ }
+
+ public void setTileErrorState(int state) {
+ preferences.edit().putInt(KEY_TILE_ERROR, state).apply();
+ }
+
+ public void registerListener(SharedPreferences.OnSharedPreferenceChangeListener listener) {
+ preferences.registerOnSharedPreferenceChangeListener(listener);
+ }
+
+ public void unregisterListener(SharedPreferences.OnSharedPreferenceChangeListener listener) {
+ preferences.unregisterOnSharedPreferenceChangeListener(listener);
+ }
+
public ExecutionMode getExecutionMode() {
return ExecutionMode.fromValue(
preferences.getInt(KEY_EXEC_MODE, ExecutionMode.NONE.getValue()));
@@ -56,7 +87,36 @@ public void setAutoSimError(boolean hasError) {
preferences.edit().putBoolean(KEY_AUTO_SIM_ERROR, hasError).apply();
}
+ public void setLastError(String command, String stderr) {
+ preferences.edit()
+ .putString(KEY_LAST_ERROR_CMD, command)
+ .putString(KEY_LAST_ERROR_STDERR, stderr)
+ .putLong(KEY_LAST_ERROR_TIMESTAMP, System.currentTimeMillis())
+ .apply();
+ }
+
+ public com.dhangofa.networktoggle.model.DiagnosticError getLastError() {
+ String cmd = preferences.getString(KEY_LAST_ERROR_CMD, null);
+ String stderr = preferences.getString(KEY_LAST_ERROR_STDERR, null);
+ long time = preferences.getLong(KEY_LAST_ERROR_TIMESTAMP, 0);
+
+ if (cmd == null && stderr == null) return null;
+ return new com.dhangofa.networktoggle.model.DiagnosticError(cmd, stderr, time);
+ }
+
+ public void clearLastError() {
+ if (getTileErrorState() == TILE_ERROR_CMD) {
+ setTileErrorState(TILE_ERROR_NONE);
+ }
+ preferences.edit()
+ .remove(KEY_LAST_ERROR_CMD)
+ .remove(KEY_LAST_ERROR_STDERR)
+ .remove(KEY_LAST_ERROR_TIMESTAMP)
+ .apply();
+ }
+
public void onExecutionModeChanged(ExecutionMode mode) {
+ setTileErrorState(TILE_ERROR_NONE);
preferences.edit()
.putInt(KEY_EXEC_MODE, mode.getValue())
.putInt(KEY_NETWORK_STATE, NetworkMode.UNKNOWN.getStateValue())
@@ -103,4 +163,91 @@ public void clearTransientState() {
.putBoolean(KEY_AUTO_SIM_ERROR, false)
.apply();
}
+
+ // --- CAPABILITY CACHING LOGIC ---
+
+ public static class NetworkCapabilities {
+ public final boolean supports2g;
+ public final boolean supports3g;
+ public final boolean supports4g;
+ public final boolean supports5g;
+
+ public NetworkCapabilities(boolean supports2g, boolean supports3g, boolean supports4g, boolean supports5g) {
+ this.supports2g = supports2g;
+ this.supports3g = supports3g;
+ this.supports4g = supports4g;
+ this.supports5g = supports5g;
+ }
+
+ // Failsafe fallback: Assume everything is supported if we can't fetch it
+ public static NetworkCapabilities assumeAll() {
+ return new NetworkCapabilities(true, true, true, true);
+ }
+ }
+
+ public void saveDeviceCapabilities(NetworkCapabilities caps) {
+ preferences.edit()
+ .putBoolean(KEY_DEVICE_CAPS_PREFIX + "2g", caps.supports2g)
+ .putBoolean(KEY_DEVICE_CAPS_PREFIX + "3g", caps.supports3g)
+ .putBoolean(KEY_DEVICE_CAPS_PREFIX + "4g", caps.supports4g)
+ .putBoolean(KEY_DEVICE_CAPS_PREFIX + "5g", caps.supports5g)
+ .apply();
+ }
+
+ public NetworkCapabilities getDeviceCapabilities() {
+ if (!preferences.contains(KEY_DEVICE_CAPS_PREFIX + "5g")) {
+ return null; // Return null so the resolver knows it needs to fetch them
+ }
+ return new NetworkCapabilities(
+ preferences.getBoolean(KEY_DEVICE_CAPS_PREFIX + "2g", true),
+ preferences.getBoolean(KEY_DEVICE_CAPS_PREFIX + "3g", true),
+ preferences.getBoolean(KEY_DEVICE_CAPS_PREFIX + "4g", true),
+ preferences.getBoolean(KEY_DEVICE_CAPS_PREFIX + "5g", true)
+ );
+ }
+
+ public void saveSlotCapabilities(int slotIndex, int subId, NetworkCapabilities caps) {
+ preferences.edit()
+ .putInt(KEY_SLOT_SUBID_PREFIX + slotIndex, subId)
+ .putBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_2g", caps.supports2g)
+ .putBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_3g", caps.supports3g)
+ .putBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_4g", caps.supports4g)
+ .putBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g", caps.supports5g)
+ .apply();
+ }
+
+ public int getCachedSubIdForSlot(int slotIndex) {
+ return preferences.getInt(KEY_SLOT_SUBID_PREFIX + slotIndex, -1);
+ }
+
+ public NetworkCapabilities getSlotCapabilities(int slotIndex) {
+ if (!preferences.contains(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g")) {
+ return null;
+ }
+ return new NetworkCapabilities(
+ preferences.getBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_2g", true),
+ preferences.getBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_3g", true),
+ preferences.getBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_4g", true),
+ preferences.getBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g", true)
+ );
+ }
+
+ public void clearDeviceCapabilities() {
+ preferences.edit()
+ .remove(KEY_DEVICE_CAPS_PREFIX + "2g")
+ .remove(KEY_DEVICE_CAPS_PREFIX + "3g")
+ .remove(KEY_DEVICE_CAPS_PREFIX + "4g")
+ .remove(KEY_DEVICE_CAPS_PREFIX + "5g")
+ .apply();
+ }
+
+ public void invalidateSlotCache(int slotIndex) {
+ preferences.edit()
+ .remove(KEY_SLOT_SUBID_PREFIX + slotIndex)
+ .remove(KEY_SLOT_CAPS_PREFIX + slotIndex + "_2g")
+ .remove(KEY_SLOT_CAPS_PREFIX + slotIndex + "_3g")
+ .remove(KEY_SLOT_CAPS_PREFIX + slotIndex + "_4g")
+ .remove(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g")
+ .apply();
+ }
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java b/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java
index 95155b1..2c007d1 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java
@@ -71,10 +71,39 @@ public ChangeResult setSelected(NetworkMode mode, boolean selected) {
}
appPreferences.setTileCycleModes(cycle);
- appPreferences.clearCachedNetworkMode();
+ // Cache is purposefully kept alive so UI changes can sync network state
return ChangeResult.CHANGED;
}
+ public boolean forceRemoveUnsupportedAndAutoFill(AppPreferences.NetworkCapabilities caps) {
+ List cycle = getCycle();
+ boolean changed = false;
+
+ if (!caps.supports5g) {
+ changed |= cycle.remove(NetworkMode.PREFERRED_5G);
+ changed |= cycle.remove(NetworkMode.FIVE_G_ONLY);
+ }
+ if (!caps.supports3g) {
+ changed |= cycle.remove(NetworkMode.PREFERRED_3G);
+ }
+ if (!caps.supports2g) {
+ changed |= cycle.remove(NetworkMode.TWO_G_ONLY);
+ }
+
+ if (changed) {
+ // Auto fill to reach MIN_MODES
+ if (cycle.size() < MIN_MODES) {
+ if (!cycle.contains(NetworkMode.PREFERRED_4G)) cycle.add(NetworkMode.PREFERRED_4G);
+ if (cycle.size() < MIN_MODES && caps.supports5g && !cycle.contains(NetworkMode.PREFERRED_5G)) cycle.add(NetworkMode.PREFERRED_5G);
+ if (cycle.size() < MIN_MODES && !cycle.contains(NetworkMode.FOUR_G_ONLY)) cycle.add(NetworkMode.FOUR_G_ONLY);
+ }
+ appPreferences.setTileCycleModes(cycle);
+ // Cache is purposefully kept alive so UI changes can sync network state
+ return true;
+ }
+ return false;
+ }
+
public boolean isValid(List cycle) {
if (cycle == null || cycle.size() < MIN_MODES || cycle.size() > MAX_MODES) {
return false;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java b/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java
new file mode 100644
index 0000000..713143d
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java
@@ -0,0 +1,13 @@
+package com.dhangofa.networktoggle.model;
+
+public class DiagnosticError {
+ public final String command;
+ public final String stderr;
+ public final long timestamp;
+
+ public DiagnosticError(String command, String stderr, long timestamp) {
+ this.command = command;
+ this.stderr = stderr;
+ this.timestamp = timestamp;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java b/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java
index 4e9b10c..2f0d0ce 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java
@@ -5,7 +5,9 @@ public enum NetworkMode {
FOUR_G_ONLY(1, "4G Only", "4G Only", "4G", "1000000000000"),
FIVE_G_ONLY(2, "5G Only", "5G Only", "5G", "10000000000000000000"),
PREFERRED_5G(3, "Preferred 5G", "Pref 5G", "P5G", "11011111101111111111"),
- PREFERRED_4G(4, "Preferred 4G", "Pref 4G", "P4G", "1001101001110000111");
+ PREFERRED_4G(4, "Preferred 4G", "Pref 4G", "P4G", "1011111101111111111"),
+ PREFERRED_3G(5, "Preferred 3G", "Pref 3G", "P3G", "11110101111111111"),
+ TWO_G_ONLY(6, "2G Only", "2G Only", "2G", "1000000000000011");
private final int stateValue;
private final String displayName;
@@ -40,6 +42,8 @@ public static NetworkMode nextInDefaultCycle(NetworkMode current) {
case FIVE_G_ONLY: return PREFERRED_5G;
case PREFERRED_5G: return PREFERRED_4G;
case PREFERRED_4G:
+ case PREFERRED_3G:
+ case TWO_G_ONLY:
case UNKNOWN:
default: return FOUR_G_ONLY;
}
@@ -48,6 +52,21 @@ public static NetworkMode nextInDefaultCycle(NetworkMode current) {
public static NetworkMode fromLegacyMode(Integer legacyMode) {
if (legacyMode == null) return UNKNOWN;
switch (legacyMode) {
+ case 1:
+ case 16:
+ return TWO_G_ONLY;
+ case 0:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 13:
+ case 14:
+ case 18:
+ case 21: // Global 3G (CDMA+EVDO+GSM+WCDMA)
+ return PREFERRED_3G;
case 11: return FOUR_G_ONLY;
case 23: return FIVE_G_ONLY;
case 33: return PREFERRED_5G;
@@ -59,7 +78,7 @@ public static NetworkMode fromLegacyMode(Integer legacyMode) {
case 17:
case 19:
case 20:
- case 22:
+ case 22: // Global 4G (LTE+CDMA+EVDO+GSM+WCDMA)
return PREFERRED_4G;
default:
if (legacyMode >= 24 && legacyMode <= 32) return PREFERRED_5G;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java
new file mode 100644
index 0000000..4b99aea
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java
@@ -0,0 +1,93 @@
+package com.dhangofa.networktoggle.telephony;
+
+import java.util.Arrays;
+import java.util.List;
+
+public final class LteAndAboveCarrierRegistry {
+
+ // REGION: INDIA
+ private static final List INDIA_LTE_AND_ABOVE = Arrays.asList(
+ "jio", "ind-jio", "jio 4g", "jio true5g"
+ );
+
+ // REGION: USA
+ // Verizon, AT&T, and T-Mobile have fully shut down 2G/3G networks as of 2026. Dish is 5G only.
+ private static final List USA_LTE_AND_ABOVE = Arrays.asList(
+ "verizon", "vzw", "at&t", "att", "t-mobile", "tmobile", "dish", "dish wireless"
+ );
+
+ // REGION: JAPAN
+ // Docomo, KDDI (au), SoftBank, and Rakuten have shut down their 3G networks.
+ private static final List JAPAN_LTE_AND_ABOVE = Arrays.asList(
+ "docomo", "ntt docomo", "au", "kddi", "softbank", "rakuten", "rakuten mobile"
+ );
+
+ // REGION: AUSTRALIA
+ // Telstra, Optus, and Vodafone Australia (TPG) have fully shut down 3G.
+ private static final List AUSTRALIA_LTE_AND_ABOVE = Arrays.asList(
+ "telstra", "optus", "vodafone au", "vodafone australia", "tpg"
+ );
+
+ // REGION: TAIWAN
+ // Chunghwa Telecom, Taiwan Mobile, and Far EasTone fully shut down 3G in mid-2024.
+ private static final List TAIWAN_LTE_AND_ABOVE = Arrays.asList(
+ "chunghwa", "chunghwa telecom", "taiwan mobile", "far eastone", "fet"
+ );
+
+ // REGION: SINGAPORE
+ // Singtel, StarHub, and M1 fully shut down 3G in 2024.
+ private static final List SINGAPORE_LTE_AND_ABOVE = Arrays.asList(
+ "singtel", "starhub", "m1"
+ );
+
+ // REGION: SOUTH KOREA
+ // LG U+ never had 3G and shut down 2G. (SK Telecom and KT still operate 3G as of 2026).
+ private static final List SOUTHKOREA_LTE_AND_ABOVE = Arrays.asList(
+ "lg u+", "lgu+", "lg uplus", "uplus"
+ );
+
+ private LteAndAboveCarrierRegistry() {
+ // Private constructor
+ }
+
+ /**
+ * Stage 4: Checks if the carrier name belongs to a known LTE/5G only network.
+ */
+ public static boolean isLteAndAboveOnly(String carrierName) {
+ if (carrierName == null || carrierName.trim().isEmpty()) {
+ return false;
+ }
+
+ String normalized = carrierName.toLowerCase().trim();
+
+ for (String name : INDIA_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : USA_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : JAPAN_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : AUSTRALIA_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : TAIWAN_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : SINGAPORE_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ for (String name : SOUTHKOREA_LTE_AND_ABOVE) {
+ if (normalized.equals(name) || normalized.contains(name)) return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java
new file mode 100644
index 0000000..c92f1b4
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java
@@ -0,0 +1,157 @@
+package com.dhangofa.networktoggle.telephony;
+
+import android.os.Build;
+
+import com.dhangofa.networktoggle.command.CommandExecutor;
+import com.dhangofa.networktoggle.command.CommandExecutorFactory;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.config.AppPreferences.NetworkCapabilities;
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+
+public final class NetworkCapabilityResolver {
+ private final AppPreferences appPreferences;
+ private final SimResolver simResolver;
+
+ public NetworkCapabilityResolver(AppPreferences appPreferences, SimResolver simResolver) {
+ this.appPreferences = appPreferences;
+ this.simResolver = simResolver;
+ }
+
+ public NetworkCapabilities getCapabilities(ExecutionMode mode) {
+ if (mode == ExecutionMode.NONE) return NetworkCapabilities.assumeAll();
+
+ // ONE SINGLE CALL to get slotIndex, subId, and carrierName!
+ SimResolver.SimInfo simInfo = simResolver.resolveTargetSimInfo(mode);
+
+ if (simInfo == null || !simResolver.isValidSlotIndex(simInfo.slotIndex) || !simResolver.isValidSubId(simInfo.subId)) {
+ return NetworkCapabilities.assumeAll();
+ }
+
+ int slotIndex = simInfo.slotIndex;
+ int subId = simInfo.subId;
+ String carrierName = simInfo.carrierName;
+
+ // Check Cache
+ int cachedSubId = appPreferences.getCachedSubIdForSlot(slotIndex);
+ NetworkCapabilities cachedCaps = appPreferences.getSlotCapabilities(slotIndex);
+
+ if (cachedSubId == subId && cachedCaps != null) {
+ if (LteAndAboveCarrierRegistry.isLteAndAboveOnly(carrierName)) {
+ return new NetworkCapabilities(false, false, cachedCaps.supports4g, cachedCaps.supports5g);
+ }
+ return cachedCaps;
+ }
+
+ // Invalidate cache and fetch
+ appPreferences.invalidateSlotCache(slotIndex);
+
+ // Stage 1 & 2: Global Device/OS Capabilities
+ NetworkCapabilities deviceCaps = fetchDeviceCapabilities(mode);
+
+ // Stage 3 & 4: Slot-Specific Carrier Capabilities (Pass carrierName directly)
+ NetworkCapabilities carrierCaps = fetchCarrierCapabilities(mode, slotIndex, carrierName);
+
+ // Combine
+ NetworkCapabilities finalCaps = new NetworkCapabilities(
+ deviceCaps.supports2g && carrierCaps.supports2g,
+ deviceCaps.supports3g && carrierCaps.supports3g,
+ deviceCaps.supports4g && carrierCaps.supports4g,
+ deviceCaps.supports5g && carrierCaps.supports5g
+ );
+
+ CommandExecutor executor = CommandExecutorFactory.forMode(mode);
+ if (executor != null) {
+ CommandResult pingResult = executor.execute("echo test");
+ if (pingResult.isSuccess()) {
+ appPreferences.saveSlotCapabilities(slotIndex, subId, finalCaps);
+ }
+ }
+ return finalCaps;
+ }
+
+ private NetworkCapabilities fetchDeviceCapabilities(ExecutionMode mode) {
+ NetworkCapabilities cachedDevice = appPreferences.getDeviceCapabilities();
+ if (cachedDevice != null) return cachedDevice;
+
+ boolean supports5g = false;
+ boolean canCache = true;
+
+ // Stage 1: Android Version Check (Android 11 / API 30+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ CommandExecutor executor = CommandExecutorFactory.forMode(mode);
+ if (executor != null) {
+ // Stage 2: Hardware Ceiling Check
+ CommandResult result = executor.execute("getprop ro.telephony.default_network");
+ if (result.isSuccess()) {
+ if (!result.getStdout().trim().isEmpty()) {
+ String[] values = result.getStdout().trim().split(",");
+
+ // Check if ANY value globally supports 5G (>= 23)
+ for (String val : values) {
+ Integer parsed = ShellValueParser.extractFirstInt(val);
+ if (parsed != null && parsed >= 23) {
+ supports5g = true;
+ break;
+ }
+ }
+ }
+ } else {
+ canCache = false;
+ }
+ } else {
+ canCache = false;
+ }
+ }
+
+ NetworkCapabilities deviceCaps = new NetworkCapabilities(true, true, true, supports5g);
+ if (canCache) {
+ appPreferences.saveDeviceCapabilities(deviceCaps);
+ }
+ return deviceCaps;
+ }
+
+ private NetworkCapabilities fetchCarrierCapabilities(ExecutionMode mode, int slotIndex, String carrierName) {
+ CommandExecutor executor = CommandExecutorFactory.forMode(mode);
+ if (executor == null) return NetworkCapabilities.assumeAll();
+
+ // Stage 3: Carrier Config XML Verification
+ String command = "dumpsys carrier_config | grep -E 'Phone Id|hide_enable_2g_bool|carrier_supports_2g_bool|hide_enable_3g_bool|carrier_supports_3g_bool|carrier_nr_availabilities_int_array'";
+ CommandResult result = executor.execute(command);
+
+ boolean supports2g = true;
+ boolean supports3g = true;
+ boolean supports5g = true;
+
+ if (result.isSuccess() && !result.getStdout().trim().isEmpty()) {
+ String[] lines = result.getStdout().split("\\n");
+ boolean inTargetSlot = false;
+
+ for (String line : lines) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("Phone Id =")) {
+ Integer currentSlot = ShellValueParser.extractFirstInt(trimmed);
+ inTargetSlot = (currentSlot != null && currentSlot == slotIndex);
+ } else if (inTargetSlot) {
+ if (trimmed.contains("hide_enable_2g_bool = true") || trimmed.contains("carrier_supports_2g_bool = false")) {
+ supports2g = false;
+ }
+ if (trimmed.contains("hide_enable_3g_bool = true") || trimmed.contains("carrier_supports_3g_bool = false")) {
+ supports3g = false;
+ }
+ if (trimmed.startsWith("carrier_nr_availabilities_int_array = []")) {
+ supports5g = false;
+ }
+ }
+ }
+ }
+
+ // Stage 4: Smart Blocklist Check
+ if (LteAndAboveCarrierRegistry.isLteAndAboveOnly(carrierName)) {
+ supports2g = false;
+ supports3g = false;
+ }
+
+ return new NetworkCapabilities(supports2g, supports3g, true, supports5g);
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java
index 68dc0f4..1897832 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java
@@ -7,15 +7,20 @@
import com.dhangofa.networktoggle.model.ExecutionMode;
import com.dhangofa.networktoggle.model.NetworkMode;
import com.dhangofa.networktoggle.model.TargetSim;
+import android.content.Context;
+import android.provider.Settings;
public final class NetworkModeReader {
+ private final Context context;
private final AppPreferences appPreferences;
private final SimResolver simResolver;
public NetworkModeReader(
+ Context context,
AppPreferences appPreferences,
SimResolver simResolver
) {
+ this.context = context;
this.appPreferences = appPreferences;
this.simResolver = simResolver;
}
@@ -29,6 +34,24 @@ public NetworkMode readCurrentMode() {
TargetSim targetSim = appPreferences.getTargetSim();
int targetSubId = simResolver.resolveTargetSubId(executionMode);
+ // NATIVE API FAST-PATH:
+ // Try reading natively without spawning shell if we have a valid SubId
+ if (simResolver.isValidSubId(targetSubId)) {
+ try {
+ String nativeValue = Settings.Global.getString(
+ context.getContentResolver(),
+ "preferred_network_mode" + targetSubId
+ );
+
+ if (nativeValue != null && !nativeValue.trim().isEmpty() && !nativeValue.equalsIgnoreCase("null")) {
+ return NetworkMode.fromLegacyMode(ShellValueParser.extractFirstInt(nativeValue));
+ }
+ } catch (Exception ignored) {
+ // Ignore SecurityExceptions or null pointers, proceed to shell fallback
+ }
+ }
+
+ // SHELL FALLBACK:
String command;
if (simResolver.isValidSubId(targetSubId)) {
command = "value=$(settings get global preferred_network_mode"
@@ -37,6 +60,7 @@ public NetworkMode readCurrentMode() {
+ "&& [ \"$value\" != \"null\" ]; then "
+ "echo \"$value\"; else exit 1; fi";
} else if (targetSim == TargetSim.AUTO) {
+ // Very slow nested shell fallback if native SubId resolution entirely failed
command = "data_sim=$(settings get global multi_sim_data_call); "
+ "[ \"$data_sim\" -gt 0 ] 2>/dev/null || exit 1; "
+ "settings get global preferred_network_mode${data_sim}";
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java
index 078f5af..296a6c9 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java
@@ -3,13 +3,14 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-final class ShellValueParser {
- private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d+");
+public final class ShellValueParser {
+ // Updated regex to support negative numbers (e.g., -1 for invalid slot)
+ private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+");
private ShellValueParser() {
}
- static Integer extractFirstInt(String text) {
+ public static Integer extractFirstInt(String text) {
try {
if (text == null
|| text.trim().isEmpty()
@@ -23,4 +24,35 @@ static Integer extractFirstInt(String text) {
return null;
}
}
+
+ /**
+ * Finds a specific key (e.g., "simSlotIndex=") and extracts the integer immediately after it.
+ */
+ public static Integer extractIntByKey(String text, String key) {
+ if (text == null || key == null) return null;
+ int index = text.indexOf(key);
+ if (index == -1) return null;
+
+ // Pass only the remainder of the string to find the first int
+ String remainder = text.substring(index + key.length());
+ return extractFirstInt(remainder);
+ }
+
+ /**
+ * Extracts a string value located between a starting key and an ending delimiter.
+ */
+ public static String extractStringByKey(String text, String key, String endDelimiter) {
+ if (text == null || key == null) return "";
+ int start = text.indexOf(key);
+ if (start == -1) return "";
+ start += key.length();
+
+ int end = text.indexOf(endDelimiter, start);
+ if (end == -1) {
+ end = text.indexOf(" ", start); // fallback to next space
+ if (end == -1) end = text.length();
+ }
+
+ return text.substring(start, end).trim();
+ }
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java
index 3f961ba..ba0dd4e 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java
@@ -1,6 +1,10 @@
package com.dhangofa.networktoggle.telephony;
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
import android.os.Build;
+import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import com.dhangofa.networktoggle.command.CommandExecutor;
@@ -14,121 +18,133 @@ public final class SimResolver {
public static final int INVALID_SLOT_INDEX = -1;
public static final int INVALID_SUB_ID = -1;
+ private final Context context;
private final AppPreferences appPreferences;
- public SimResolver(AppPreferences appPreferences) {
- this.appPreferences = appPreferences;
- }
-
- public int resolveTargetSlotIndex(ExecutionMode executionMode) {
- TargetSim targetSim = appPreferences.getTargetSim();
+ // Data class to hold all extracted variables in one place
+ public static class SimInfo {
+ public final int subId;
+ public final int slotIndex;
+ public final String carrierName;
- if (!targetSim.isAuto()) {
- appPreferences.setAutoSimError(false);
- return targetSim.getManualSlotIndex();
+ public SimInfo(int subId, int slotIndex, String carrierName) {
+ this.subId = subId;
+ this.slotIndex = slotIndex;
+ this.carrierName = carrierName;
}
+ }
- return resolveAutoSlotIndex(executionMode);
+ public SimResolver(Context context, AppPreferences appPreferences) {
+ this.context = context.getApplicationContext();
+ this.appPreferences = appPreferences;
}
- public int resolveTargetSubId(ExecutionMode executionMode) {
+ /**
+ * Resolves IDs using Native APIs first.
+ * Silently falls back to Shell commands if permission is denied or device is too old.
+ */
+ public SimInfo resolveTargetSimInfo(ExecutionMode executionMode) {
TargetSim targetSim = appPreferences.getTargetSim();
-
- if (targetSim == TargetSim.AUTO) {
- int subId = SubscriptionManager.getDefaultDataSubscriptionId();
- return isValidSubId(subId) ? subId : INVALID_SUB_ID;
+
+ int targetSubId = INVALID_SUB_ID;
+ int targetSlotIndex = INVALID_SLOT_INDEX;
+ String carrierName = "";
+
+ // 1. Safe Native APIs (No permission required)
+ if (targetSim.isAuto()) {
+ targetSubId = SubscriptionManager.getDefaultDataSubscriptionId();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ try {
+ int nativeSlot = SubscriptionManager.getSlotIndex(targetSubId);
+ if (isValidSlotIndex(nativeSlot)) targetSlotIndex = nativeSlot;
+ } catch (Exception ignored) {}
+ }
+ } else {
+ targetSlotIndex = targetSim.getManualSlotIndex();
}
- return resolveSubIdFromDumpsys(
- executionMode,
- targetSim.getManualSlotIndex()
- );
- }
-
- public boolean isValidSlotIndex(int slotIndex) {
- return slotIndex == 0 || slotIndex == 1;
- }
-
- public boolean isValidSubId(int subId) {
- return subId != SubscriptionManager.INVALID_SUBSCRIPTION_ID
- && subId != INVALID_SUB_ID;
- }
-
- private int resolveAutoSlotIndex(ExecutionMode executionMode) {
- int dataSubId = SubscriptionManager.getDefaultDataSubscriptionId();
-
- if (!isValidSubId(dataSubId)) {
- appPreferences.setAutoSimError(true);
- return INVALID_SLOT_INDEX;
+ // 2. Protected Native APIs (Requires READ_PHONE_STATE)
+ boolean hasPermission = context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
+
+ if (hasPermission) {
+ SubscriptionManager sm = context.getSystemService(SubscriptionManager.class);
+ if (sm != null) {
+ try {
+ if (isValidSubId(targetSubId)) {
+ SubscriptionInfo info = sm.getActiveSubscriptionInfo(targetSubId);
+ if (info != null) {
+ if (info.getCarrierName() != null) carrierName = info.getCarrierName().toString();
+ if (!isValidSlotIndex(targetSlotIndex)) targetSlotIndex = info.getSimSlotIndex();
+ }
+ } else if (isValidSlotIndex(targetSlotIndex)) {
+ for (SubscriptionInfo info : sm.getActiveSubscriptionInfoList()) {
+ if (info.getSimSlotIndex() == targetSlotIndex) {
+ targetSubId = info.getSubscriptionId();
+ if (info.getCarrierName() != null) carrierName = info.getCarrierName().toString();
+ break;
+ }
+ }
+ }
+ } catch (Exception ignored) {}
+ }
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- try {
- int slotIndex = SubscriptionManager.getSlotIndex(dataSubId);
- if (isValidSlotIndex(slotIndex)) {
- appPreferences.setAutoSimError(false);
- return slotIndex;
+ // 3. Shell Fallback (If missing permission, or older Android OS failed to map)
+ if (!isValidSubId(targetSubId) || !isValidSlotIndex(targetSlotIndex) || carrierName.isEmpty()) {
+ String command = "dumpsys isub | grep -E \"\\{id=[0-9]+ .*simSlotIndex=\"";
+ CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
+
+ if (executor != null) {
+ CommandResult result = executor.execute(command);
+ if (result.isSuccess() && !result.getStdout().trim().isEmpty()) {
+ String[] lines = result.getStdout().split("\\n");
+ for (String line : lines) {
+ Integer dumpSubId = ShellValueParser.extractIntByKey(line, "id=");
+ Integer dumpSlotIndex = ShellValueParser.extractIntByKey(line, "simSlotIndex=");
+ String dumpCarrier = ShellValueParser.extractStringByKey(line, "carrierName=", " nameSource=");
+
+ if (dumpSubId != null && dumpSlotIndex != null) {
+ if (targetSim.isAuto() && dumpSubId == targetSubId) {
+ if (!isValidSlotIndex(targetSlotIndex)) targetSlotIndex = dumpSlotIndex;
+ if (carrierName.isEmpty()) carrierName = dumpCarrier;
+ break;
+ } else if (!targetSim.isAuto() && dumpSlotIndex == targetSlotIndex) {
+ if (!isValidSubId(targetSubId)) targetSubId = dumpSubId;
+ if (carrierName.isEmpty()) carrierName = dumpCarrier;
+ break;
+ }
+ }
+ }
}
- } catch (Exception ignored) {
}
}
- int slotIndex = resolveSlotIndexFromDumpsys(executionMode, dataSubId);
- if (isValidSlotIndex(slotIndex)) {
- appPreferences.setAutoSimError(false);
- return slotIndex;
+ // 4. Final Validation
+ if (isValidSubId(targetSubId) && isValidSlotIndex(targetSlotIndex)) {
+ if (targetSim.isAuto()) appPreferences.setAutoSimError(false);
+ return new SimInfo(targetSubId, targetSlotIndex, carrierName);
}
- appPreferences.setAutoSimError(true);
- return INVALID_SLOT_INDEX;
+ if (targetSim.isAuto()) appPreferences.setAutoSimError(true);
+ return null;
+ }
+
+ // Keeping backwards compatibility for existing app logic
+ public int resolveTargetSlotIndex(ExecutionMode executionMode) {
+ SimInfo info = resolveTargetSimInfo(executionMode);
+ return info != null ? info.slotIndex : INVALID_SLOT_INDEX;
}
- private int resolveSlotIndexFromDumpsys(
- ExecutionMode executionMode,
- int dataSubId
- ) {
- String command = "dumpsys isub | grep -E \"\\{id=" + dataSubId
- + "([^0-9]| )\" | head -n 1 "
- + "| grep -o -E \"simSlotIndex=[0-9]+\" "
- + "| cut -d '=' -f 2";
-
- CommandResult result = execute(executionMode, command);
- Integer slotIndex = result.isSuccess()
- ? ShellValueParser.extractFirstInt(result.getStdout())
- : null;
-
- return slotIndex != null && isValidSlotIndex(slotIndex)
- ? slotIndex
- : INVALID_SLOT_INDEX;
+ public int resolveTargetSubId(ExecutionMode executionMode) {
+ SimInfo info = resolveTargetSimInfo(executionMode);
+ return info != null ? info.subId : INVALID_SUB_ID;
}
- private int resolveSubIdFromDumpsys(
- ExecutionMode executionMode,
- int slotIndex
- ) {
- String command = "dumpsys isub | grep -E \"simSlotIndex=" + slotIndex
- + "([^0-9]| )\" | head -n 1 "
- + "| grep -o -E \"\\{id=[0-9]+\" "
- + "| cut -d '=' -f 2";
-
- CommandResult result = execute(executionMode, command);
- Integer subId = result.isSuccess()
- ? ShellValueParser.extractFirstInt(result.getStdout())
- : null;
-
- return subId != null && subId > 0
- ? subId
- : INVALID_SUB_ID;
+ public boolean isValidSlotIndex(int slotIndex) {
+ return slotIndex == 0 || slotIndex == 1;
}
- private CommandResult execute(
- ExecutionMode executionMode,
- String command
- ) {
- CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
- if (executor == null) {
- return CommandResult.failed(command, "No execution mode selected.");
- }
- return executor.execute(command);
+ public boolean isValidSubId(int subId) {
+ return subId != SubscriptionManager.INVALID_SUBSCRIPTION_ID && subId != INVALID_SUB_ID;
}
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java
index 7b06b9e..f16c70b 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java
@@ -1,11 +1,13 @@
package com.dhangofa.networktoggle.ui;
import android.app.Activity;
+import android.view.View;
import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast;
import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
import com.dhangofa.networktoggle.cycle.TileCycleManager;
import com.dhangofa.networktoggle.model.NetworkMode;
@@ -14,42 +16,154 @@
public final class TileCycleUiController {
private final Activity activity;
private final TileCycleManager cycleManager;
- private final CheckBox mode5gOnly;
- private final CheckBox mode4gOnly;
+
private final CheckBox modePref5g;
private final CheckBox modePref4g;
+ private final CheckBox modePref3g;
+ private final CheckBox mode5gOnly;
+ private final CheckBox mode4gOnly;
+ private final CheckBox mode2gOnly;
+
+ private final View separatorCyclePref1;
+ private final View separatorCyclePref2;
+ private final View separatorCycleOnly1;
+ private final View separatorCycleOnly2;
+
private final TextView selectedCount;
private final TextView cycleOrder;
private boolean updatingUi;
+ private AppPreferences.NetworkCapabilities currentCaps;
+ private OnCycleChangedListener cycleChangedListener;
+ private boolean isAuthorized = true;
+
+ public interface OnCycleChangedListener {
+ void onCycleChanged(List newCycle);
+ }
+
public TileCycleUiController(Activity activity, TileCycleManager cycleManager) {
this.activity = activity;
this.cycleManager = cycleManager;
- mode5gOnly = activity.findViewById(R.id.cycle5gOnly);
- mode4gOnly = activity.findViewById(R.id.cycle4gOnly);
+
modePref5g = activity.findViewById(R.id.cyclePreferred5g);
modePref4g = activity.findViewById(R.id.cyclePreferred4g);
+ modePref3g = activity.findViewById(R.id.cyclePreferred3g);
+ mode5gOnly = activity.findViewById(R.id.cycle5gOnly);
+ mode4gOnly = activity.findViewById(R.id.cycle4gOnly);
+ mode2gOnly = activity.findViewById(R.id.cycle2gOnly);
+
+ separatorCyclePref1 = activity.findViewById(R.id.separatorCyclePref1);
+ separatorCyclePref2 = activity.findViewById(R.id.separatorCyclePref2);
+ separatorCycleOnly1 = activity.findViewById(R.id.separatorCycleOnly1);
+ separatorCycleOnly2 = activity.findViewById(R.id.separatorCycleOnly2);
+
selectedCount = activity.findViewById(R.id.cycleSelectedCount);
cycleOrder = activity.findViewById(R.id.cycleOrderText);
}
+ public void setOnCycleChangedListener(OnCycleChangedListener listener) {
+ this.cycleChangedListener = listener;
+ }
+
public void initialize() {
refresh();
- mode5gOnly.setOnCheckedChangeListener((button, selected) ->
- handleSelection(NetworkMode.FIVE_G_ONLY, selected));
- mode4gOnly.setOnCheckedChangeListener((button, selected) ->
- handleSelection(NetworkMode.FOUR_G_ONLY, selected));
+
+ android.view.View.OnTouchListener lockTouch = (v, event) -> {
+ if (!isAuthorized && event.getAction() == android.view.MotionEvent.ACTION_DOWN) {
+ showToast("Please authorize Root or Shizuku to configure toggles.");
+ return true; // Consume event to prevent visual change
+ }
+ return false;
+ };
+ modePref5g.setOnTouchListener(lockTouch);
+ modePref4g.setOnTouchListener(lockTouch);
+ modePref3g.setOnTouchListener(lockTouch);
+ mode5gOnly.setOnTouchListener(lockTouch);
+ mode4gOnly.setOnTouchListener(lockTouch);
+ mode2gOnly.setOnTouchListener(lockTouch);
+
modePref5g.setOnCheckedChangeListener((button, selected) ->
handleSelection(NetworkMode.PREFERRED_5G, selected));
modePref4g.setOnCheckedChangeListener((button, selected) ->
handleSelection(NetworkMode.PREFERRED_4G, selected));
+ modePref3g.setOnCheckedChangeListener((button, selected) ->
+ handleSelection(NetworkMode.PREFERRED_3G, selected));
+ mode5gOnly.setOnCheckedChangeListener((button, selected) ->
+ handleSelection(NetworkMode.FIVE_G_ONLY, selected));
+ mode4gOnly.setOnCheckedChangeListener((button, selected) ->
+ handleSelection(NetworkMode.FOUR_G_ONLY, selected));
+ mode2gOnly.setOnCheckedChangeListener((button, selected) ->
+ handleSelection(NetworkMode.TWO_G_ONLY, selected));
+ }
+
+ public void setAuthorized(boolean authorized) {
+ if (this.isAuthorized == authorized) return;
+ this.isAuthorized = authorized;
+ float alpha = authorized ? 1.0f : 0.4f;
+
+ View card = activity.findViewById(R.id.cardTileCycle);
+ if (card != null) {
+ card.setAlpha(alpha);
+ }
+ }
+
+ public void applyCapabilities(AppPreferences.NetworkCapabilities caps) {
+ if (caps == null) return;
+ this.currentCaps = caps;
+
+ modePref5g.setAlpha(caps.supports5g ? 1.0f : 0.4f);
+ mode5gOnly.setAlpha(caps.supports5g ? 1.0f : 0.4f);
+
+ modePref3g.setAlpha(caps.supports3g ? 1.0f : 0.4f);
+
+ mode2gOnly.setAlpha(caps.supports2g ? 1.0f : 0.4f);
+
+ if (cycleManager.forceRemoveUnsupportedAndAutoFill(caps)) {
+ showToast("Cycle auto-adjusted for current SIM capabilities");
+ if (cycleChangedListener != null) {
+ cycleChangedListener.onCycleChanged(cycleManager.getCycle());
+ }
+ }
+
+ refresh();
}
private void handleSelection(NetworkMode mode, boolean selected) {
if (updatingUi) return;
+ if (!isAuthorized) {
+ showToast("Please authorize Root or Shizuku to configure toggles.");
+ refresh(); // Revert checkbox visual change
+ return;
+ }
+
+ if (selected && currentCaps != null) {
+ boolean supported = true;
+ String reason = "";
+ if ((mode == NetworkMode.PREFERRED_5G || mode == NetworkMode.FIVE_G_ONLY) && !currentCaps.supports5g) {
+ supported = false;
+ reason = "5G is not supported by your device or current carrier.";
+ } else if (mode == NetworkMode.PREFERRED_3G && !currentCaps.supports3g) {
+ supported = false;
+ reason = "3G is disabled or not supported by your current carrier.";
+ } else if (mode == NetworkMode.TWO_G_ONLY && !currentCaps.supports2g) {
+ supported = false;
+ reason = "2G is disabled or not supported by your current carrier.";
+ }
+
+ if (!supported) {
+ showToast(reason);
+ refresh(); // Revert UI to match the actual saved cycle
+ return;
+ }
+ }
+
TileCycleManager.ChangeResult result = cycleManager.setSelected(mode, selected);
- if (result == TileCycleManager.ChangeResult.MINIMUM_REACHED) {
+ if (result == TileCycleManager.ChangeResult.CHANGED) {
+ if (cycleChangedListener != null) {
+ cycleChangedListener.onCycleChanged(cycleManager.getCycle());
+ }
+ } else if (result == TileCycleManager.ChangeResult.MINIMUM_REACHED) {
showToast("Select at least 2 tile modes.");
} else if (result == TileCycleManager.ChangeResult.MAXIMUM_REACHED) {
showToast("You can select up to 3 tile modes.");
@@ -60,10 +174,20 @@ private void handleSelection(NetworkMode mode, boolean selected) {
private void refresh() {
updatingUi = true;
List cycle = cycleManager.getCycle();
- mode5gOnly.setChecked(cycle.contains(NetworkMode.FIVE_G_ONLY));
- mode4gOnly.setChecked(cycle.contains(NetworkMode.FOUR_G_ONLY));
+
modePref5g.setChecked(cycle.contains(NetworkMode.PREFERRED_5G));
modePref4g.setChecked(cycle.contains(NetworkMode.PREFERRED_4G));
+ modePref3g.setChecked(cycle.contains(NetworkMode.PREFERRED_3G));
+ mode5gOnly.setChecked(cycle.contains(NetworkMode.FIVE_G_ONLY));
+ mode4gOnly.setChecked(cycle.contains(NetworkMode.FOUR_G_ONLY));
+ mode2gOnly.setChecked(cycle.contains(NetworkMode.TWO_G_ONLY));
+
+ // Hide separators if either adjacent button is checked to create a seamless pill background
+ separatorCyclePref1.setVisibility(modePref5g.isChecked() || modePref4g.isChecked() ? View.INVISIBLE : View.VISIBLE);
+ separatorCyclePref2.setVisibility(modePref4g.isChecked() || modePref3g.isChecked() ? View.INVISIBLE : View.VISIBLE);
+ separatorCycleOnly1.setVisibility(mode5gOnly.isChecked() || mode4gOnly.isChecked() ? View.INVISIBLE : View.VISIBLE);
+ separatorCycleOnly2.setVisibility(mode4gOnly.isChecked() || mode2gOnly.isChecked() ? View.INVISIBLE : View.VISIBLE);
+
selectedCount.setText("Selected: " + cycle.size() + "/3");
cycleOrder.setText(buildOrderText(cycle));
updatingUi = false;
@@ -82,4 +206,3 @@ private void showToast(String message) {
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show();
}
}
-
diff --git a/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java b/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java
new file mode 100644
index 0000000..c70c9e0
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java
@@ -0,0 +1,48 @@
+package com.dhangofa.networktoggle.util;
+
+import android.os.Build;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.DiagnosticError;
+import com.dhangofa.networktoggle.telephony.SimResolver;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+public class DiagnosticReporter {
+
+ public static String generateReport(AppPreferences prefs, SimResolver simResolver) {
+ DiagnosticError error = prefs.getLastError();
+ if (error == null) {
+ return "No recent errors recorded.";
+ }
+
+ StringBuilder sb = new StringBuilder();
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
+
+ sb.append("--- NETWORK TOGGLE DIAGNOSTIC REPORT ---\n");
+ sb.append("Time: ").append(sdf.format(new Date(error.timestamp))).append("\n\n");
+
+ sb.append("[DEVICE INFO]\n");
+ sb.append("Manufacturer: ").append(Build.MANUFACTURER).append("\n");
+ sb.append("Brand: ").append(Build.BRAND).append("\n");
+ sb.append("Device: ").append(Build.DEVICE).append("\n");
+ sb.append("Model: ").append(Build.MODEL).append("\n");
+ sb.append("Android Version: ").append(Build.VERSION.RELEASE).append("\n");
+ sb.append("SDK Level: ").append(Build.VERSION.SDK_INT).append("\n\n");
+
+ sb.append("[APP STATE]\n");
+ sb.append("Execution Mode: ").append(prefs.getExecutionMode().name()).append("\n");
+ sb.append("Target SIM Setting: ").append(prefs.getTargetSim().name()).append("\n");
+
+ int slotIndex = simResolver.resolveTargetSlotIndex(prefs.getExecutionMode());
+ sb.append("Resolved Slot Index: ").append(slotIndex).append("\n\n");
+
+ sb.append("[ERROR DETAILS]\n");
+ sb.append("Command Attempted:\n").append(error.command).append("\n\n");
+ sb.append("Standard Error (stderr):\n").append(error.stderr).append("\n\n");
+
+ sb.append("--- END OF REPORT ---");
+
+ return sb.toString();
+ }
+}
diff --git a/app/src/main/res/color/color_segmented_text_cycle.xml b/app/src/main/res/color/color_segmented_text_cycle.xml
new file mode 100644
index 0000000..d9152f6
--- /dev/null
+++ b/app/src/main/res/color/color_segmented_text_cycle.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/color/color_segmented_text_exec.xml b/app/src/main/res/color/color_segmented_text_exec.xml
new file mode 100644
index 0000000..49c7c53
--- /dev/null
+++ b/app/src/main/res/color/color_segmented_text_exec.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml
new file mode 100644
index 0000000..173ef1f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_right.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..852af25
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_speed.xml b/app/src/main/res/drawable/ic_speed.xml
new file mode 100644
index 0000000..1c926a1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_speed.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml
new file mode 100644
index 0000000..269f9a5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_warning.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/section_outline_bg.xml b/app/src/main/res/drawable/section_outline_bg.xml
index 18966ae..98e1e46 100644
--- a/app/src/main/res/drawable/section_outline_bg.xml
+++ b/app/src/main/res/drawable/section_outline_bg.xml
@@ -2,13 +2,10 @@
-
-
+
+ android:left="16dp"
+ android:top="16dp"
+ android:right="16dp"
+ android:bottom="16dp" />
diff --git a/app/src/main/res/drawable/selector_segmented_tab.xml b/app/src/main/res/drawable/selector_segmented_tab.xml
index 3377f2c..5dfadb5 100644
--- a/app/src/main/res/drawable/selector_segmented_tab.xml
+++ b/app/src/main/res/drawable/selector_segmented_tab.xml
@@ -3,14 +3,14 @@
-
-
+
-
-
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_cycle.xml b/app/src/main/res/drawable/selector_segmented_tab_cycle.xml
new file mode 100644
index 0000000..89b5b3f
--- /dev/null
+++ b/app/src/main/res/drawable/selector_segmented_tab_cycle.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_exec.xml b/app/src/main/res/drawable/selector_segmented_tab_exec.xml
new file mode 100644
index 0000000..28f41d0
--- /dev/null
+++ b/app/src/main/res/drawable/selector_segmented_tab_exec.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_rect.xml b/app/src/main/res/drawable/selector_segmented_tab_rect.xml
new file mode 100644
index 0000000..66d1e06
--- /dev/null
+++ b/app/src/main/res/drawable/selector_segmented_tab_rect.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_app_icon_bg.xml b/app/src/main/res/drawable/shape_app_icon_bg.xml
new file mode 100644
index 0000000..6db87ae
--- /dev/null
+++ b/app/src/main/res/drawable/shape_app_icon_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_button_primary.xml b/app/src/main/res/drawable/shape_button_primary.xml
new file mode 100644
index 0000000..efa2239
--- /dev/null
+++ b/app/src/main/res/drawable/shape_button_primary.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_cycle_card_bg.xml b/app/src/main/res/drawable/shape_cycle_card_bg.xml
new file mode 100644
index 0000000..b2d7950
--- /dev/null
+++ b/app/src/main/res/drawable/shape_cycle_card_bg.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_radio_item_bg.xml b/app/src/main/res/drawable/shape_cycle_order_badge.xml
similarity index 59%
rename from app/src/main/res/drawable/shape_radio_item_bg.xml
rename to app/src/main/res/drawable/shape_cycle_order_badge.xml
index e57ef6a..e8ebe00 100644
--- a/app/src/main/res/drawable/shape_radio_item_bg.xml
+++ b/app/src/main/res/drawable/shape_cycle_order_badge.xml
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/shape_dialog_bg.xml b/app/src/main/res/drawable/shape_dialog_bg.xml
new file mode 100644
index 0000000..98d1f63
--- /dev/null
+++ b/app/src/main/res/drawable/shape_dialog_bg.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_exec_card_bg.xml b/app/src/main/res/drawable/shape_exec_card_bg.xml
new file mode 100644
index 0000000..27e9033
--- /dev/null
+++ b/app/src/main/res/drawable/shape_exec_card_bg.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_floating_footer.xml b/app/src/main/res/drawable/shape_floating_footer.xml
new file mode 100644
index 0000000..f420756
--- /dev/null
+++ b/app/src/main/res/drawable/shape_floating_footer.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_qs_hint_bg.xml b/app/src/main/res/drawable/shape_qs_hint_bg.xml
index e7140d2..8f2c988 100644
--- a/app/src/main/res/drawable/shape_qs_hint_bg.xml
+++ b/app/src/main/res/drawable/shape_qs_hint_bg.xml
@@ -2,8 +2,5 @@
-
-
+
diff --git a/app/src/main/res/drawable/shape_segmented_container.xml b/app/src/main/res/drawable/shape_segmented_container.xml
index 93b7f91..3537330 100644
--- a/app/src/main/res/drawable/shape_segmented_container.xml
+++ b/app/src/main/res/drawable/shape_segmented_container.xml
@@ -2,6 +2,6 @@
-
+
diff --git a/app/src/main/res/drawable/shape_segmented_container_rect.xml b/app/src/main/res/drawable/shape_segmented_container_rect.xml
new file mode 100644
index 0000000..9047d42
--- /dev/null
+++ b/app/src/main/res/drawable/shape_segmented_container_rect.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_small_divider.xml b/app/src/main/res/drawable/shape_small_divider.xml
new file mode 100644
index 0000000..9ee6d7c
--- /dev/null
+++ b/app/src/main/res/drawable/shape_small_divider.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/social_icon_bg.xml b/app/src/main/res/drawable/social_icon_bg.xml
index 0bd8189..fd5528c 100644
--- a/app/src/main/res/drawable/social_icon_bg.xml
+++ b/app/src/main/res/drawable/social_icon_bg.xml
@@ -1,8 +1,6 @@
+ android:shape="rectangle">
-
+
diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml
new file mode 100644
index 0000000..34f4c44
--- /dev/null
+++ b/app/src/main/res/layout-land/activity_main.xml
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index ad77aef..9784d05 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,405 +1,262 @@
-
+ android:orientation="vertical"
+ android:background="@color/surface_background">
-
+
+ android:paddingTop="12dp"
+ android:paddingBottom="12dp"
+ android:elevation="6dp"
+ android:outlineProvider="bounds"
+ android:gravity="center_vertical">
-
-
+
+
+
+
+
-
+
+
-
-
+
+
+
-
-
+
+
+
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:orientation="horizontal"
+ android:background="@drawable/shape_qs_hint_bg"
+ android:padding="12dp"
+ android:gravity="center_vertical"
+ android:layout_marginTop="0dp"
+ android:layout_marginBottom="12dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
+ android:layout_marginBottom="12dp" />
+
+
+
+
+
+
-
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
+
-
+
-
+
-
-
-
-
-
+
+
+
+
diff --git a/app/src/main/res/layout/bottom_sheet_permission.xml b/app/src/main/res/layout/bottom_sheet_permission.xml
new file mode 100644
index 0000000..4f473b8
--- /dev/null
+++ b/app/src/main/res/layout/bottom_sheet_permission.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_diagnostic.xml b/app/src/main/res/layout/dialog_diagnostic.xml
new file mode 100644
index 0000000..3420ff4
--- /dev/null
+++ b/app/src/main/res/layout/dialog_diagnostic.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_error_banner.xml b/app/src/main/res/layout/view_error_banner.xml
new file mode 100644
index 0000000..ee18cb2
--- /dev/null
+++ b/app/src/main/res/layout/view_error_banner.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_execution_card.xml b/app/src/main/res/layout/view_execution_card.xml
new file mode 100644
index 0000000..9171237
--- /dev/null
+++ b/app/src/main/res/layout/view_execution_card.xml
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_tile_cycle_card.xml b/app/src/main/res/layout/view_tile_cycle_card.xml
index af35456..7741af6 100644
--- a/app/src/main/res/layout/view_tile_cycle_card.xml
+++ b/app/src/main/res/layout/view_tile_cycle_card.xml
@@ -2,8 +2,8 @@
@@ -20,12 +20,12 @@
android:orientation="horizontal">
+ android:tint="@color/accent_orange" />
@@ -42,11 +42,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
- android:background="@drawable/shape_pill_badge_bg"
+ android:background="@drawable/shape_cycle_order_badge"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:text="3/3"
- android:textColor="@color/brand_primary"
+ android:textColor="@color/accent_orange"
android:textSize="10sp"
android:textStyle="bold" />
@@ -62,40 +62,69 @@
+ android:orientation="horizontal"
+ android:background="@drawable/shape_segmented_container_rect"
+ android:padding="4dp">
+
+
+
+
+
+
@@ -103,40 +132,69 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
- android:orientation="horizontal">
+ android:orientation="horizontal"
+ android:background="@drawable/shape_segmented_container_rect"
+ android:padding="4dp">
+
+
+
+
+
+
@@ -145,12 +203,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
- android:background="@drawable/shape_segmented_container"
+ android:background="@drawable/shape_cycle_order_badge"
android:gravity="center"
- android:padding="8dp"
+ android:padding="10dp"
android:text="Cycle: 5G Only → 4G Only → Preferred 5G"
- android:textColor="@color/text_secondary"
- android:textSize="10sp"
+ android:textColor="@color/accent_orange"
+ android:textSize="11sp"
android:textStyle="bold" />
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index b3b7f98..7b9931a 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -2,13 +2,13 @@
#D0BCFF
- #4F378B
+ #23212A
#EADDFF
- #141218
- #211F26
- #36343B
+ #000000
+ #141218
+ #2B2930
#2B2930
@@ -18,7 +18,6 @@
#1A3822
- #A8F2B2
#3E1A19
#F2B8B5
@@ -26,8 +25,26 @@
#2B2930
+ #49454F
#1B1A20
#36323F
#EADDFF
#938F96
+
+
+ #61D48A
+ #FFB782
+ #F48FB1
+
+
+ #1A130F
+ #3D210F
+ #1B1A20
+ #FFB782
+
+
+ #57AFFF
+ #11161A
+ #1A364F
+ #8ECCFF
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index b82eec7..d4d0294 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -2,7 +2,7 @@
+