diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index da331db8..1f777fc3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,4 +1,6 @@
-name: Build and Automate NetToggle Release
+name: Build NetToggle
+
+run-name: ${{ github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' && 'Build NetToggle for New Release' || 'Build NetToggle for Testing') || github.event.pull_request.title }}
on:
workflow_dispatch:
@@ -19,7 +21,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v6
- - name: Set up Java Development Kit
+ - name: Set up Java Development Kit (JDK 17)
uses: actions/setup-java@v5
with:
java-version: "17"
@@ -27,17 +29,27 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- with:
- cache-provider: basic
- - name: Accept Licenses and Install SDK 36
+ - name: Accept Licenses and Install Android SDKs (Android 16/17 Support)
run: |
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
- "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36"
+ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" || true
+ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37" || true
- name: Make Gradle Wrapper Executable
run: chmod +x ./gradlew
+ - name: Inject Automated Versioning (Test Builds)
+ if: github.event_name == 'workflow_dispatch' && github.ref_name != 'main'
+ run: |
+ SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
+ RUN_NUM=${{ github.run_number }}
+ sed -i -E "s/versionCode *= *[0-9]+/versionCode = $RUN_NUM/" app/build.gradle.kts
+ sed -i -E "s/versionName *= *\"([^\"]+)\"/versionName = \"\1-test-$SHORT_SHA\"/" app/build.gradle.kts
+
+ echo "Injected Test Versioning:"
+ grep -E "versionCode|versionName" app/build.gradle.kts
+
- name: Compile Release APK
run: ./gradlew assembleRelease
diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml
index 6c65d621..3b6f955d 100644
--- a/.github/workflows/update-gradle-wrapper.yml
+++ b/.github/workflows/update-gradle-wrapper.yml
@@ -2,45 +2,55 @@ name: Update Gradle Wrapper
on:
workflow_dispatch:
+ inputs:
+ gradle_version:
+ description: 'Gradle version to update to'
+ required: false
+ default: '8.12.1'
+ type: string
permissions:
contents: write
jobs:
update-wrapper:
+ name: Update Gradle Wrapper
runs-on: ubuntu-latest
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - name: Checkout Code
+ uses: actions/checkout@v6
- - name: Set up JDK
- uses: actions/setup-java@v4
+ - name: Set up Java Development Kit (JDK 17)
+ uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v4
+ uses: gradle/actions/setup-gradle@v6
with:
- gradle-version: '8.7'
+ gradle-version: ${{ inputs.gradle_version || '8.12.1' }}
- - name: Fetch official Gradle checksum
+ - name: Fetch Official Gradle Checksum
id: checksum
run: |
- SHA=$(curl -fsSL https://services.gradle.org/distributions/gradle-8.7-bin.zip.sha256 | tr -d '[:space:]')
+ VERSION="${{ inputs.gradle_version || '8.12.1' }}"
+ SHA=$(curl -fsSL "https://services.gradle.org/distributions/gradle-${VERSION}-bin.zip.sha256" | tr -d '[:space:]')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- echo "Gradle 8.7 bin checksum: $SHA"
+ echo "Gradle ${VERSION} bin checksum: $SHA"
- - name: Regenerate Gradle Wrapper with checksum
+ - name: Regenerate Gradle Wrapper with Checksum
run: |
+ VERSION="${{ inputs.gradle_version || '8.12.1' }}"
gradle wrapper \
- --gradle-version 8.7 \
+ --gradle-version "$VERSION" \
--distribution-type bin \
--gradle-distribution-sha256-sum "${{ steps.checksum.outputs.sha }}"
- - name: Commit Gradle Wrapper changes
+ - name: Commit Gradle Wrapper Changes
run: |
+ VERSION="${{ inputs.gradle_version || '8.12.1' }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
@@ -49,6 +59,6 @@ jobs:
if git diff --cached --quiet; then
echo "No Gradle Wrapper changes to commit."
else
- git commit -m "Add Gradle wrapper distribution checksum"
+ git commit -m "build(gradle): update Gradle wrapper to ${VERSION} with SHA-256 checksum"
git push
fi
diff --git a/README.md b/README.md
index d13c6665..92407d74 100644
--- a/README.md
+++ b/README.md
@@ -25,141 +25,190 @@
## 📖 Overview
-NetToggle is a lightweight Android Quick Settings tile app for switching cellular network modes directly from the QS panel. It is built for users whose Android ROM, OEM skin, or carrier configuration hides strict 5G/4G controls or makes network mode switching inconvenient.
+NetToggle is a lightweight Android Quick Settings (QS) tile app for cellular network control. It allows you to create custom tile cycles, target one or both SIMs, build capability-aware shortcuts, execute secure broadcast automations, and troubleshoot network states with detailed diagnostics.
-NetToggle uses privileged shell access through **Root** or **Shizuku** to run Android telephony commands that normal Android apps cannot access. For older devices (Android 7-11), it utilizes a seamless headless Java payload, while Android 12+ relies on native routing. Network behavior can still depend on the device, ROM, modem, SIM setup, and carrier support.
+NetToggle executes privileged telephony commands via **Root** or **Shizuku** that standard Android apps cannot access. For older Android releases (Android 7–11), it utilizes an efficient headless reflection mechanism, while Android 12+ relies on native direct command dispatching.
-## ⭐ What NetToggle Can Do
+---
+
+## ⚙️ Network Modes
+
+* **Preferred 5G (5G/4G/3G/2G):** Recommended for daily use. Allows 5G SA and NSA (including UW, UC, 5G+, or 5G++ where supported) with LTE and legacy fallback.
+* **5G Only (NR Only):** Disables 4G, 3G, and 2G. Intended strictly for 5G SA networks. Service and calls may drop on NSA networks or without SA and VoNR provisioning.
+* **Preferred 4G (4G/3G/2G):** Disables 5G while retaining LTE and legacy fallback. Useful for battery saving, reducing device heat, or stabilizing connections in fringe 5G areas.
+* **4G Only (LTE/LTE-A):** Locks to LTE without 3G/2G fallback. Calls require VoLTE; carrier aggregation is controlled by the modem and network.
+* **Preferred 3G (3G/2G):** Disables 4G and 5G while allowing supported 3G and 2G networks.
+* **2G Only (GSM/EDGE):** Locks to 2G where legacy service remains available.
-- Switch network modes directly from a Quick Settings tile
-- Force **5G Only** or **4G Only / LTE Only** where supported
-- Set **Preferred 5G**, **Preferred 4G**, **Preferred 3G**, or **2G Only**
-- **Custom QS Tile Cycle**: Select 2 or 3 modes to cycle through in your preferred order
-- Use **Root** or **Shizuku** execution mode
-- Target **Auto**, **SIM 1**, or **SIM 2**
-- **Smart Capability Filtering**: Dynamically hides unsupported modes based on your device hardware capability and a built-in global 2G/3G sunset carrier registry
-- **Diagnostic Tracking**: Built-in error reporting and shell output logs (stdout/stderr) for easy troubleshooting
-- A clean, modern UI featuring interactive morphing carousels, segmented controls, and light/dark styling
+> **Note:** If calls or cellular service fail in an *Only* mode, switch back to **Preferred 5G** or **Preferred 4G**.
-## 🚧 Why It Was Made
-Modern Android skins often restrict or completely hide advanced network mode selections (such as locking the device strictly to 5G or 4G to prevent unwanted network drops). Furthermore, standard third-party Android apps are only providing pref 4G and pref 5G, not locking to 4G only or 5G only.
+---
+
+## 🔄 Quick Tile Cycle
-NetToggle bridges this gap by completely bypassing standard Android APIs. Instead of asking the system to change the network, it executes raw baseband telephony commands (via native shell or secure service reflection) as a privileged user, enabling direct network mode switching on supported devices/ROMs.
+* **Custom Sequence:** Choose an ordered cycle of 2 or 3 modes.
+* **Live State:** Shows the active mode with an Auto, SIM 1, SIM 2, or Both badge.
+* **Auto Restore:** Automatically restores your preferred mode after a configurable cooldown if Android, thermal management, or the carrier reverts it.
+* **Efficient Refresh:** Controlled checks avoid continuous polling and unnecessary battery drain.
-## ⚙️ Working Modes & Requirements
-To execute the privileged baseband commands, NetToggle requires an elevated execution environment. It features a built-in interactive setup carousel to select between two independent working modes:
+---
-### 1. Root Mode (`su`)
-* **Requirements:** A rooted device (e.g., Magisk, Apatch, or KernelSU).
+## 📱 Target SIM Selection
-### 2. Shizuku Mode (`ADB`)
-* **Requirements:** The [Shizuku](https://shizuku.rikka.app/) or its working forks application installed and running.
+* **Auto:** Dynamically resolves the active data subscription and maps it to the physical SIM slot using native Android APIs.
+* **SIM 1 or SIM 2:** Applies network mode changes to the selected physical SIM slot.
+* **Both SIMs:** Reconciles and synchronizes mutually supported modes across both SIMs simultaneously and reports any partial failures.
-## 💳 Target SIM Selection
+> **Tip:** If Auto detection fails on unusual hardware, select a SIM manually.
-* **Auto**: Detects the active data subscription and maps it to the correct physical SIM slot using native APIs.
-* **SIM 1**: Applies network mode changes to physical SIM slot 1.
-* **SIM 2**: Applies network mode changes to physical SIM slot 2.
+---
+
+## 🪄 Shortcuts & OS Routines
+
+* **Capability-Aware Actions:** Create up to 4 routine shortcuts with custom names, network modes, and SIM targets.
+* **1-Tap Launcher Widgets:** Pin direct toggle actions to your home screen launcher.
+* **OEM Automation:** Integrates seamlessly with **Samsung Modes & Routines**, **Google Assistant**, and third-party taskers.
+
+---
-This helps on dual-SIM devices where Android subscription IDs do not always match physical SIM slot numbers.
+## 📡 Broadcast Automation & ADB
-## 🔄 Quick Tile Cycle Configuration
+Control NetToggle programmatically from **Tasker**, **MacroDroid**, **Automate**, or **ADB** shell scripts:
-NetToggle allows you to build a personalized sequence of network modes that the Quick Settings tile will cycle through when tapped.
+* **Opt-In Security:** External automation is disabled by default and requires opt-in with a secret authorization token.
+* **Live ADB Command Preview:** Interactive builder generates ready-to-run shell commands and intent extras with one-tap clipboard copy.
+* **Sample ADB Shell Broadcast:**
+ ```bash
+ am broadcast -a com.dhangofa.networktoggle.SET_NETWORK_MODE \
+ -n com.dhangofa.networktoggle/.AutomationReceiver \
+ --es mode "5G" \
+ --ei sim 1 \
+ --es token "YOUR_SECRET_TOKEN"
+ ```
+* **Supported Modes:** `5G_ONLY` (or `5G`), `4G_ONLY` (or `4G`, `LTE`), `PREF_5G` (or `PREFERRED_5G`), `PREF_4G` (or `PREFERRED_4G`), `PREF_3G` (or `PREFERRED_3G`), `2G_ONLY` (or `2G`)
+* **Supported SIM Values:** `1` (SIM 1), `2` (SIM 2), `3` (Both)
+
+---
+
+## 🔌 Requirements & Permissions
+
+* **Root or Shizuku Required:** Either must be active and authorized. Shizuku is recommended and can run through Root, ADB, or Wireless Debugging (trusted forks may add start-on-boot, watchdog, or TCP mode). Root mode supports **Magisk**, **KernelSU**, **APatch**, and compatible `su` managers.
+* **Read Phone State:** Required for fast native SIM mapping and hardware capability detection. Without it, privileged shell fallbacks are used where available.
+* **Run at Startup:** Briefly clears stale tile state after boot or app update. No persistent background service is used.
+
+---
+
+## 🧠 Smart Capability Filtering
+
+* **Hardware Baseband Detection:** Evaluates device baseband properties (`ro.telephony.default_network`) to hide unsupported network modes.
+* **Dual-SIM Mutual Support:** When targeting Both SIMs, the UI automatically offers only modes supported by both slots.
+* **2G/3G Sunset Registry:** Built-in global carrier database dynamically hides deprecated legacy network options on known sunset networks.
+
+---
-* **Selectable Modes:** You can choose from **5G Only**, **4G Only**, **Preferred 5G**, **Preferred 4G**, **Preferred 3G**, and **2G Only**.
-* **Smart Filtering:** Modes that your device hardware or specific carrier does not support (based on our 2G/3G sunset registry) will be dynamically disabled to prevent misconfiguration.
-* **Cycle Rules:** You must select a minimum of 2 modes and a maximum of 3 modes. The order in which you select them dictates the exact order the Quick Settings tile will follow.
+## 🎨 Design & Themes
+
+* Clean Material 3 interface optimized for both portrait and landscape orientations.
+* Interactive setup card, segmented controls, Fluid Animations.
+* Supports **Auto (System)**, **Light**, **Dark**, and high-contrast **AMOLED** themes with custom tactile feedback.
+
+---
+
+## 🛠️ Diagnostics & Troubleshooting
+
+* **In-App Diagnostic Logs:** View and copy detailed reports including executed commands, exit codes, raw output (stdout/stderr), exceptions, execution state, and SIM resolution.
+* **Error Banners:** Immediate visual feedback with recovery guidance when a command or permission fails.
+
+---
## 📸 Screenshots
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
-## 📱 Supported Android Versions
+---
-- **Minimum:** Android 7.0, API 24
-- **Target:** Android 16, API 36
+## 📱 Supported Android Versions & ROMs
-Tested device/ROM examples include:
+- **Minimum Version:** Android 7.0 (Nougat, API 24)
+- **Target Version:** Android 17 (API 37)
-- Samsung One UI
-- HyperOS / MIUI
-- OriginOS / ColorOS / OxygenOS
-- Android custom/AOSP-based ROMs
+---
-Other OEM skins and custom ROMs may also work. If NetToggle does not work on your device, you can use the built-in diagnostic dialog to copy detailed execution logs and open an issue.
+## 🔐 Privacy & Security
-## 🔐 Privacy
+NetToggle is built strictly as an offline utility:
-NetToggle is designed to work offline.
+- 🚫 **Zero Ads**
+- 🚫 **Zero Analytics or Telemetry**
+- 🚫 **Zero Trackers**
+- 🚫 **No `android.permission.INTERNET` declared** (cannot connect to the web)
+- 🚫 **Zero Background Data Transmission**
+- 🚫 **Zero Personal Data Collection**
-- No ads
-- No analytics
-- No telemetry
-- No trackers
-- No internet permission
-- No background network communication
-- No personal data collection
-- No personal data transmission
+**Permission Transparency:**
+* `READ_PHONE_STATE`: Strictly used to query active physical SIM slot configurations and detect carrier capability ceilings via Android's native `SubscriptionManager`. All operations are executed 100% locally on your device.
-**Note on Permissions:** The `READ_PHONE_STATE` permission is strictly required to resolve active physical SIM slots via Android's native `SubscriptionManager` API and determine safe network capability ceilings. All processing remains 100% local.
-External links are opened only when the user manually taps the GitHub or Telegram icons in the app.
+---
## 🛠️ Build From Source
-NetToggle can be built using the standard Android Gradle toolchain.
+NetToggle is built with Kotlin, Java, and the standard Android Gradle toolchain.
```bash
-git clone [https://github.com/dhangofa/NetToggle.git](https://github.com/dhangofa/NetToggle.git)
+git clone https://github.com/Dhangofa/NetToggle.git
cd NetToggle
./gradlew assembleRelease
```
+
+---
+
## 📚 Documentation & Guides
-For detailed setup instructions, troubleshooting, and answers to common questions, please refer to our official Wiki pages:
+For step-by-step setup guides, routine integrations, and detailed technical troubleshooting, check out the official Wiki:
- [1. Execution Mode Configuration](https://github.com/Dhangofa/NetToggle/wiki/1.-Execution-Mode-Configuration)
- [2. Target SIM Setup & Quick Tile Cycle Guide](https://github.com/Dhangofa/NetToggle/wiki/2.-Target-SIM-Setup-&-Quick-Tile-Cycle-Guide)
- [3. Adding the Tile to Quick Settings](https://github.com/Dhangofa/NetToggle/wiki/3.-Adding-the-Tile-to-Quick-Settings)
+- [4. App Shortcuts & Built-in OS Routines](https://github.com/Dhangofa/NetToggle/wiki/4.-App-Shortcuts-&-Built%E2%80%90in-OS-Routines)
+- [5. Broadcast Automation (Tasker, MacroDroid, Automate)](https://github.com/Dhangofa/NetToggle/wiki/5.-Broadcast-Automation-(Tasker,-MacroDroid,-Automate))
- [Frequently Asked Questions (FAQ)](https://github.com/Dhangofa/NetToggle/wiki/Frequently-Asked-Questions-(FAQ))
-## 💬 Contact
-
-For project-related questions, issue follow-up, or contact verification:
+---
-- [Github issues](https://github.com/Dhangofa/NetToggle/issues)
-- [Telegram](https://t.me/dhangofas_projects_chat)
+## 💬 Community & Support
+* **Bug Reports & Feature Requests:** [GitHub Issues](https://github.com/Dhangofa/NetToggle/issues)
+* **Community Discussion:** [Telegram Group](https://t.me/dhangofas_projects_chat)
-For bugs or feature requests, please use GitHub Issues when possible so device details, logs, and discussions stay trackable.
+---
## 📜 License
-NetToggle is licensed under the **GNU General Public License v3.0**.
-See the [LICENSE](https://github.com/Dhangofa/NetToggle/tree/main/LICENSE) file for the full license text.
+
+NetToggle is open-source software licensed under the **GNU General Public License v3.0**.
+See the [LICENSE](LICENSE) file for details.
+
+---
## 🧾 Notices
-NetToggle uses custom vector assets created or edited for this project.
+NetToggle uses original vector assets and icons created or customized specifically for this project.
-- The NetToggle launcher icon was created for this project using AI-assisted generation and manual vector editing.
-- The Quick Settings network icon was created for this project.
-- The app UI uses custom outlined vector icons created or edited for this project.
-- The GitHub and Telegram icons are used only as external link indicators.
-- GitHub trademarks remain the property of their respective owners.
-- NetToggle does not use third-party icon packs as app identity assets.
+- The NetToggle launcher icon and QS network icons were created for this project.
+- UI outlines and vector assets are customized for Material Design 3 guidelines.
+- Third-party trademarks (such as Android, Samsung One UI, Magisk, Shizuku, GitHub, Telegram) belong to their respective copyright holders.
-See [NOTICE.md](NOTICE.md) for more details.
+See [NOTICE.md](NOTICE.md) for further information.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fc8d1aeb..cf5d3bbe 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,9 +9,9 @@ android {
defaultConfig {
applicationId = "com.dhangofa.networktoggle"
minSdk = 24
- targetSdk = 36
- versionCode = 33
- versionName = "1.1.0"
+ targetSdk = 37
+ versionCode = 34
+ versionName = "1.2.0"
}
// Suggested by IzzyOnDroid
dependenciesInfo {
@@ -43,6 +43,7 @@ android {
}
dependencies {
+ implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
// Shizuku API
implementation("dev.rikka.shizuku:api:13.1.5")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 70dc52bc..880bdb98 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ android:label="NetToggle"
+ android:theme="@style/Theme.NetToggle.Splash"
+ android:windowSoftInputMode="adjustResize">
@@ -53,7 +76,7 @@
diff --git a/app/src/main/java/com/dhangofa/networktoggle/AutomationReceiver.java b/app/src/main/java/com/dhangofa/networktoggle/AutomationReceiver.java
new file mode 100644
index 00000000..3f1e2204
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/AutomationReceiver.java
@@ -0,0 +1,81 @@
+package com.dhangofa.networktoggle;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.dhangofa.networktoggle.automation.AutomationExecutor;
+import com.dhangofa.networktoggle.automation.AutomationRequest;
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+import com.dhangofa.networktoggle.util.AppExecutors;
+
+public class AutomationReceiver extends BroadcastReceiver {
+ private static final String TAG = "AutomationReceiver";
+ private static final String ACTION_SET_MODE = "com.dhangofa.networktoggle.SET_MODE";
+ private static final String EXTRA_MODE = "mode";
+ private static final String EXTRA_SIM = "sim";
+ private static final String EXTRA_TOKEN = "token";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !ACTION_SET_MODE.equals(intent.getAction())) {
+ return;
+ }
+
+ com.dhangofa.networktoggle.config.AppPreferences prefs = new com.dhangofa.networktoggle.config.AppPreferences(context);
+
+ if (!prefs.isExternalAutomationEnabled()) {
+ Log.e(TAG, "External automation is disabled in settings.");
+ return;
+ }
+
+ String expectedToken = prefs.getAutomationToken();
+ String receivedToken = intent.getStringExtra(EXTRA_TOKEN);
+
+ if (expectedToken == null || expectedToken.trim().isEmpty() || receivedToken == null || !receivedToken.equals(expectedToken)) {
+ Log.e(TAG, "Automation token mismatch, missing, or not configured. Action blocked.");
+ return;
+ }
+
+ String modeString = intent.getStringExtra(EXTRA_MODE);
+ if (modeString == null || modeString.isEmpty()) {
+ Log.e(TAG, "No mode provided in intent extra '" + EXTRA_MODE + "'");
+ return;
+ }
+
+ NetworkMode targetMode = NetworkMode.fromString(modeString);
+ if (targetMode == NetworkMode.UNKNOWN) {
+ Log.e(TAG, "Unknown mode requested: " + modeString);
+ return;
+ }
+
+ int simOverride = intent.getIntExtra(EXTRA_SIM, -1);
+ TargetSim targetSim = TargetSim.AUTO; // Default fallback to match current config, usually overridden by preferences inside executor
+ if (simOverride == 1) targetSim = TargetSim.SIM_1;
+ else if (simOverride == 2) targetSim = TargetSim.SIM_2;
+ else if (simOverride == 3) targetSim = TargetSim.BOTH;
+ else {
+ // Fallback to saved preference if sim is not specified or invalid.
+ targetSim = prefs.getTargetSim();
+ }
+
+ final PendingResult pendingResult = goAsync();
+ final TargetSim finalTargetSim = targetSim;
+
+ try {
+ AppExecutors.executeTelephony(() -> {
+ try {
+ AutomationRequest request = new AutomationRequest(targetMode, finalTargetSim, true, "BroadcastReceiver");
+ AutomationExecutor.execute(context, request);
+ } finally {
+ pendingResult.finish();
+ }
+ });
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to submit execution to executors.", e);
+ pendingResult.finish();
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/BootReceiver.java b/app/src/main/java/com/dhangofa/networktoggle/BootReceiver.java
index 99ba90bf..c49363dd 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/BootReceiver.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/BootReceiver.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle;
+/**
+ * Broadcast receiver for system boot and app update events.
+ * Clears out any stale transient state (like temporary errors or cached
+ * network modes) so the app can start completely fresh after a device reboot or app update.
+ */
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/MainActivity.java b/app/src/main/java/com/dhangofa/networktoggle/MainActivity.java
index b2da7177..07764f2e 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/MainActivity.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/MainActivity.java
@@ -1,838 +1,293 @@
-/*
-Behavior:
-- Fresh install: no execution mode selected.
-- Root selected: root permission checked.
-- Shizuku selected: Shizuku permission checked/requested only then.
-- Saved Root: root rechecked on open.
-- Saved Shizuku: status checked without auto-popup.
-- Shizuku callbacks update UI only when Shizuku mode is selected.
-
-Target SIM behavior:
-- Target SIM defaults to Auto.
-- Auto mode is handled by the QS tile service using the active data subscription.
-- Auto mode resolves the active data subscription to the physical SIM slot before applying changes.
-- If Auto SIM detection fails, the QS tile sets auto_sim_error=true and shows a Toast.
-- MainActivity shows a persistent warning when auto_sim_error=true.
-- Selecting any Target SIM option clears the previous Auto SIM warning.
-- SIM 1 and SIM 2 use the chosen physical SIM slot directly.
-*/
-
package com.dhangofa.networktoggle;
import android.app.Activity;
-import android.content.Intent;
-import android.content.pm.PackageInfo;
-import android.content.pm.PackageManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.SharedPreferences;
import android.content.res.Configuration;
-import android.net.Uri;
-import android.os.Build;
import android.os.Bundle;
-import android.view.View;
+import android.service.quicksettings.TileService;
+import android.view.MotionEvent;
import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.RadioButton;
import android.widget.RadioGroup;
-import android.widget.TextView;
+import com.dhangofa.networktoggle.NetworkTileService;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.automation.AutomationExecutor;
+import com.dhangofa.networktoggle.automation.AutomationRequest;
import com.dhangofa.networktoggle.config.AppPreferences;
-import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.cycle.TileCycleManager;
import com.dhangofa.networktoggle.model.NetworkMode;
import com.dhangofa.networktoggle.model.TargetSim;
-import com.dhangofa.networktoggle.cycle.TileCycleManager;
-import com.dhangofa.networktoggle.telephony.NetworkModeController;
import com.dhangofa.networktoggle.telephony.NetworkCapabilityResolver;
+import com.dhangofa.networktoggle.telephony.NetworkModeController;
+import com.dhangofa.networktoggle.telephony.NetworkModeReader;
import com.dhangofa.networktoggle.telephony.SimResolver;
+import com.dhangofa.networktoggle.ui.BroadcastTabHelper;
+import com.dhangofa.networktoggle.ui.ExecutionStateController;
+import com.dhangofa.networktoggle.ui.PhoneStatePermissionManager;
+import com.dhangofa.networktoggle.ui.ShortcutTabHelper;
+import com.dhangofa.networktoggle.ui.TargetSimUiController;
+import com.dhangofa.networktoggle.ui.TileCycleSyncController;
import com.dhangofa.networktoggle.ui.TileCycleUiController;
-import android.app.Dialog;
-import android.view.Gravity;
-import android.view.ViewGroup;
-import android.view.Window;
-import android.graphics.Color;
-import android.graphics.drawable.ColorDrawable;
-import android.content.ClipData;
-import android.content.ClipboardManager;
-import android.widget.Toast;
-import com.dhangofa.networktoggle.model.DiagnosticError;
-import com.dhangofa.networktoggle.util.DiagnosticReporter;
-
-
-import rikka.shizuku.Shizuku;
+import com.dhangofa.networktoggle.util.AppExecutors;
-public class MainActivity extends Activity implements android.content.SharedPreferences.OnSharedPreferenceChangeListener {
-
- private RadioGroup radioGroup;
- private RadioButton radioRoot;
- private RadioButton radioShizuku;
- private TextView appVersionText;
- private TextView statusText;
- private ImageView githubLink;
- private ImageView telegramLink;
+import java.util.List;
- private RadioGroup targetSimRadioGroup;
- private RadioButton radioSimAuto;
- private RadioButton radioSim1;
- private RadioButton radioSim2;
- private TextView autoSimWarningText;
-
- private ImageView faqLink;
-
- // Morphing View Carousel
- private View carouselBackground;
- private TextView carouselTitle;
- private TextView carouselDesc;
- private TextView carouselButtonText;
- private ImageView carouselIcon;
- private ImageView carouselButtonIcon;
- private View carouselButton;
- private View carouselIconContainer;
- private LinearLayout carouselIndicators;
-
- private int currentCarouselIndex = 0;
- private float touchStartX = 0f;
- private CarouselItem[] carouselItems;
-
- private static class CarouselItem {
- String title, desc, btnText, url;
- int iconRes, bgColor, accentColor, btnBgColor;
- CarouselItem(String title, String desc, String btnText, String url, int iconRes, int bgColor, int accentColor, int btnBgColor) {
- this.title = title; this.desc = desc; this.btnText = btnText; this.url = url;
- this.iconRes = iconRes; this.bgColor = bgColor; this.accentColor = accentColor; this.btnBgColor = btnBgColor;
- }
- }
-
- private View separatorRootShizuku;
- private View separatorAutoSim1;
- private View separatorSim1Sim2;
+public class MainActivity extends Activity implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private com.dhangofa.networktoggle.ui.DiagnosticUiController diagnosticUiController;
+ private com.dhangofa.networktoggle.ui.ExecutionModeUiController executionModeUiController;
+ private com.dhangofa.networktoggle.ui.MainNavigationController navigationController;
- private Dialog permissionDialog;
- private View errorBannerContainer;
- private Dialog diagnosticDialog;
+ private com.dhangofa.networktoggle.ui.ThemeController themeController;
+ private PhoneStatePermissionManager permissionManager;
private NetworkCapabilityResolver capabilityResolver;
-
private static final int REQ_CODE_PHONE_STATE = 1001;
-
private AppPreferences appPreferences;
- private TileCycleUiController tileCycleUiController;
+ private TileCycleUiController tileCycleUiController;
private volatile boolean activityDestroyed;
- private Thread rootCheckThread;
- private Process rootCheckProcess;
-
- private final Shizuku.OnBinderReceivedListener binderReceivedListener = () ->
- runOnUiThread(() -> {
- if (!activityDestroyed
- && appPreferences != null
- && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
- checkShizukuPermission(false);
- }
- });
-
- private final Shizuku.OnBinderDeadListener binderDeadListener = () ->
- runOnUiThread(() -> {
- if (!activityDestroyed
- && appPreferences != null
- && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
- setStatus("Shizuku is not running.", 0xFFFF5555);
- }
- });
-
- private final Shizuku.OnRequestPermissionResultListener permissionResultListener =
- (requestCode, grantResult) -> runOnUiThread(() -> {
- if (!activityDestroyed
- && appPreferences != null
- && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
- checkShizukuPermission(false);
- }
- });
+ private ExecutionStateController executionStateController;
+ private TargetSimUiController targetSimUiController;
+ private BroadcastTabHelper broadcastTabHelper;
+ private ShortcutTabHelper shortcutTabHelper;
+ private SimResolver simResolver;
+ private NetworkModeController modeController;
+ private NetworkModeReader modeReader;
+ private TargetSim lastTargetSim = TargetSim.AUTO;
+
+ public int getCurrentTabIndex() {
+ return this.navigationController != null ? this.navigationController.getCurrentTabIndex() : 0;
+ }
+ public com.dhangofa.networktoggle.ui.MainNavigationController getNavigationController() { return this.navigationController; }
+ public ShortcutTabHelper getShortcutTabHelper() { return this.shortcutTabHelper; }
+ public BroadcastTabHelper getBroadcastTabHelper() { return this.broadcastTabHelper; }
+ public com.dhangofa.networktoggle.ui.ThemeController getThemeController() { return this.themeController; }
@Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- activityDestroyed = false;
-
- configureStatusBar();
- setContentView(R.layout.activity_main);
-
- appPreferences = new AppPreferences(this);
- appPreferences.registerListener(this);
- bindViews();
- appVersionText.setText("v" + getAppVersionName());
- bindLinks();
- SimResolver simResolver = new SimResolver(this, appPreferences);
- capabilityResolver = new NetworkCapabilityResolver(appPreferences, simResolver);
- TileCycleManager tileCycleManager = new TileCycleManager(appPreferences);
- tileCycleUiController = new TileCycleUiController(this, tileCycleManager);
- NetworkModeController modeController = new NetworkModeController(simResolver);
-
- tileCycleUiController.setOnCycleChangedListener(newCycle -> {
- 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();
- setupCarousel();
- setupStaticLandscapeCards();
+ public boolean dispatchTouchEvent(android.view.MotionEvent ev) {
+ if (this.navigationController != null) {
+ return this.navigationController.dispatchTouchEvent(ev);
+ }
+ return super.dispatchTouchEvent(ev);
}
- private void configureStatusBar() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;
-
- boolean isNight = (getResources().getConfiguration().uiMode
- & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
- getWindow().setStatusBarColor(getColor(R.color.card_surface));
- getWindow().getDecorView().setSystemUiVisibility(
- isNight ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+ public boolean superDispatchTouchEvent(MotionEvent ev) {
+ return super.dispatchTouchEvent(ev);
}
- private void bindViews() {
- radioGroup = findViewById(R.id.modeRadioGroup);
- radioRoot = findViewById(R.id.radioRoot);
- radioShizuku = findViewById(R.id.radioShizuku);
- statusText = findViewById(R.id.shizukuStatusText);
- appVersionText = findViewById(R.id.appVersionText);
- targetSimRadioGroup = findViewById(R.id.targetSimRadioGroup);
- radioSimAuto = findViewById(R.id.radioSimAuto);
- radioSim1 = findViewById(R.id.radioSim1);
- radioSim2 = findViewById(R.id.radioSim2);
- autoSimWarningText = findViewById(R.id.autoSimWarningText);
- githubLink = findViewById(R.id.githubLink);
- telegramLink = findViewById(R.id.telegramLink);
-
- faqLink = findViewById(R.id.faqLink);
- carouselBackground = findViewById(R.id.carouselBackground);
- carouselTitle = findViewById(R.id.carouselTitle);
- carouselDesc = findViewById(R.id.carouselDesc);
- carouselButtonText = findViewById(R.id.carouselButtonText);
- carouselIcon = findViewById(R.id.carouselIcon);
- carouselButtonIcon = findViewById(R.id.carouselButtonIcon);
- carouselButton = findViewById(R.id.carouselButton);
- carouselIconContainer = findViewById(R.id.carouselIconContainer);
- carouselIndicators = findViewById(R.id.carouselIndicators);
- 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());
+ protected void attachBaseContext(Context newBase) {
+ SharedPreferences prefs = newBase.getSharedPreferences("AppPrefs", 0);
+ int mode = prefs.getInt("app_theme", 1);
+ Configuration config = new Configuration(newBase.getResources().getConfiguration());
+ if (mode == 1) {
+ config.uiMode = config.uiMode & 0xFFFFFFCF | 0x10;
+ } else if (mode == 2 || mode == 3) {
+ config.uiMode = config.uiMode & 0xFFFFFFCF | 0x20;
}
-
-
+ super.attachBaseContext(newBase.createConfigurationContext(config));
}
-
- private void bindLinks() {
- githubLink.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle"));
- telegramLink.setOnClickListener(v -> openUrl("https://t.me/dhangofas_projects_chat"));
- if (faqLink != null) faqLink.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle/wiki"));
+ public boolean isAmoled() {
+ return this.themeController != null && this.themeController.isAmoled();
}
-
- private void setupStaticLandscapeCards() {
- View btn1 = findViewById(R.id.staticCardButton1);
- if (btn1 != null) {
- btn1.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle/wiki/1.-Execution-Mode-Configuration"));
- }
- View btn2 = findViewById(R.id.staticCardButton2);
- if (btn2 != null) {
- btn2.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle/wiki/2.-Target-SIM-Setup-&-Quick-Tile-Cycle-Guide"));
- }
- View btn3 = findViewById(R.id.staticCardButton3);
- if (btn3 != null) {
- btn3.setOnClickListener(v -> openUrl("https://github.com/Dhangofa/NetToggle/wiki/3.-Adding-the-Tile-to-Quick-Settings"));
+ protected void onCreate(Bundle savedInstanceState) {
+ int savedTab = 0;
+ int savedAutomationSubTab = -1;
+ if (savedInstanceState != null) {
+ savedTab = savedInstanceState.getInt("savedTab", 0);
+ savedAutomationSubTab = savedInstanceState.getInt("savedAutomationSubTab", -1);
}
- }
-
- private void setupCarousel() {
- if (carouselBackground == null) return;
- carouselItems = new CarouselItem[]{
- new CarouselItem("New to NetToggle?", "Learn how to set up Execution Modes", "Read Guide", "https://github.com/Dhangofa/NetToggle/wiki/1.-Execution-Mode-Configuration", R.drawable.ic_terminal, R.color.first_pg_bg, R.color.exec_accent, R.color.view_guide_button_bg),
- new CarouselItem("Target SIM & Cycle", "Learn how to configure your modes", "Read Guide", "https://github.com/Dhangofa/NetToggle/wiki/2.-Target-SIM-Setup-&-Quick-Tile-Cycle-Guide", R.drawable.ic_sim_card, R.color.second_pg_bg, R.color.accent_orange, R.color.view_guide_button_bg),
- new CarouselItem("Quick Settings Ready", "Add the tile to your Control Center", "Read Guide", "https://github.com/Dhangofa/NetToggle/wiki/3.-Adding-the-Tile-to-Quick-Settings", R.drawable.ic_network_bars, R.color.third_pg_bg, R.color.accent_pink, R.color.view_guide_button_bg)
- };
-
- setupCarouselIndicators(carouselItems.length);
- // Start state depends on initial auth state.
- // We defer to updateCarouselContext which will be triggered by loadSavedExecutionMode -> setStatus -> updateAuthorizationUI
- // But let's set a default page just in case
- renderCarouselPage(isUIAuthorized ? 1 : 0, false, 0);
+ this.navigationController = new com.dhangofa.networktoggle.ui.MainNavigationController(this, savedTab, savedAutomationSubTab);
+ SharedPreferences prefs = this.getSharedPreferences("AppPrefs", 0);
+ this.themeController = new com.dhangofa.networktoggle.ui.ThemeController(this, prefs);
+ super.onCreate(savedInstanceState);
+ this.activityDestroyed = false;
+ this.themeController.configureStatusBar();
+ this.setContentView(R.layout.activity_main);
+ ImageView btnThemeToggle = this.findViewById(R.id.btnThemeToggle);
+ this.themeController.bindViews(btnThemeToggle, this.findViewById(R.id.navIndicatorPill));
+ this.appPreferences = new AppPreferences((Context)this);
+ this.lastTargetSim = this.appPreferences.getTargetSim();
+ this.appPreferences.registerListener(this);
+
+ this.diagnosticUiController = new com.dhangofa.networktoggle.ui.DiagnosticUiController(this, this.appPreferences);
+ this.diagnosticUiController.bindViews();
+
+ com.dhangofa.networktoggle.ui.AppFooterHelper.setupFooter(this);
+ this.simResolver = new SimResolver((Context)this, this.appPreferences);
+ this.capabilityResolver = new NetworkCapabilityResolver(this.appPreferences, this.simResolver);
+ TileCycleManager tileCycleManager = new TileCycleManager(this.appPreferences);
+ this.tileCycleUiController = new TileCycleUiController(this, tileCycleManager, this.appPreferences);
+ this.modeController = new NetworkModeController(this.simResolver);
+ this.modeReader = new NetworkModeReader((Context)this, this.appPreferences, this.simResolver);
+ this.permissionManager = new PhoneStatePermissionManager(this, REQ_CODE_PHONE_STATE, this.appPreferences, this::updateCapabilities);
- carouselBackground.setOnTouchListener((v, event) -> {
- switch (event.getAction()) {
- case android.view.MotionEvent.ACTION_DOWN:
- touchStartX = event.getX();
- return true;
- case android.view.MotionEvent.ACTION_UP:
- case android.view.MotionEvent.ACTION_CANCEL:
- float deltaX = event.getX() - touchStartX;
- if (Math.abs(deltaX) > 100) {
- if (deltaX > 0) {
- // Swipe right (previous page)
- currentCarouselIndex = (currentCarouselIndex - 1 + carouselItems.length) % carouselItems.length;
- renderCarouselPage(currentCarouselIndex, true, -1);
- } else if (deltaX < 0) {
- // Swipe left (next page)
- currentCarouselIndex = (currentCarouselIndex + 1) % carouselItems.length;
- renderCarouselPage(currentCarouselIndex, true, 1);
- }
-
-
- } else if (event.getAction() == android.view.MotionEvent.ACTION_UP) {
- v.performClick();
- }
- return true;
+ this.executionStateController = new ExecutionStateController(this, this.appPreferences, (text, color) -> {
+ if (this.executionModeUiController != null) {
+ this.executionModeUiController.setStatus(text, color);
}
- return false;
- });
-
- carouselBackground.setOnClickListener(v -> {
- // Needed to ensure ripple works when performClick is called
});
+ this.executionModeUiController = new com.dhangofa.networktoggle.ui.ExecutionModeUiController(this, this.appPreferences, this.executionStateController, this::updateAuthorizationUI);
+ this.executionModeUiController.setOnModeChangeListener(mode -> this.updateCapabilities());
+ this.executionModeUiController.bindViews();
+
+ TileCycleSyncController tileCycleSyncController = new TileCycleSyncController((Context)this, this.appPreferences, this.simResolver, this.modeController);
+ this.tileCycleUiController.setOnCycleChangedListener(tileCycleSyncController);
+ this.tileCycleUiController.initialize();
+ this.executionStateController.registerListeners();
+ this.targetSimUiController = new TargetSimUiController(this, this.appPreferences, this::onTargetSimSelectionChanged);
+ this.targetSimUiController.initialize();
+ this.broadcastTabHelper = BroadcastTabHelper.setupTab(this, this.appPreferences);
+ this.shortcutTabHelper = ShortcutTabHelper.setupTab(this, this.appPreferences);
+ com.dhangofa.networktoggle.ui.GuidesTabHelper.setupTab(this);
+ this.updateAuthorizationUI(isExecutionAuthorized());
+ this.navigationController.initialize();
+ this.navigationController.setupKeyboardListener();
+ this.navigationController.setupOrientationListener();
}
- private void renderCarouselPage(int index, boolean animate, int direction) {
- CarouselItem item = carouselItems[index];
- Runnable updateViews = () -> {
- carouselTitle.setText(item.title);
- carouselDesc.setText(item.desc);
- carouselButtonText.setText(item.btnText);
- carouselButtonText.setTextColor(getColor(item.accentColor));
-
- carouselIcon.setImageResource(item.iconRes);
- carouselIcon.setColorFilter(getColor(item.accentColor));
- carouselButtonIcon.setColorFilter(getColor(item.accentColor));
-
- carouselBackground.setBackgroundTintList(android.content.res.ColorStateList.valueOf(getColor(item.bgColor)));
- carouselButton.setBackgroundTintList(android.content.res.ColorStateList.valueOf(getColor(item.btnBgColor)));
- carouselIconContainer.setBackgroundTintList(android.content.res.ColorStateList.valueOf(getColor(item.btnBgColor)));
-
- carouselButton.setOnClickListener(v -> {
- try { startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(item.url))); } catch (Exception e) {}
- });
- updateCarouselIndicators(index);
- };
-
- if (animate) {
- float moveOutX = direction * -100f; // Swipe left (next) -> content moves left (-100).
- float moveInX = direction * 100f; // Swipe left -> new content comes from right (+100).
- long duration = 200;
-
- // Animate background color smoothly
- android.animation.ValueAnimator colorAnim = android.animation.ValueAnimator.ofObject(
- new android.animation.ArgbEvaluator(),
- carouselBackground.getBackgroundTintList().getDefaultColor(),
- getColor(item.bgColor)
- );
- colorAnim.setDuration(duration);
- colorAnim.addUpdateListener(animator -> {
- carouselBackground.setBackgroundTintList(android.content.res.ColorStateList.valueOf((int) animator.getAnimatedValue()));
- });
- colorAnim.start();
-
- // Animate content out
- android.view.View[] viewsToAnimate = {carouselTitle, carouselDesc, carouselButton, carouselIconContainer};
- for (android.view.View view : viewsToAnimate) {
- view.animate().translationX(moveOutX).alpha(0f).setDuration(duration / 2)
- .setInterpolator(new android.view.animation.AccelerateInterpolator())
- .withEndAction(() -> {
- // Once out, swap data
- if (view == carouselTitle) updateViews.run();
-
- // Prepare for animate in
- view.setTranslationX(moveInX);
- view.animate().translationX(0f).alpha(1f).setDuration(duration / 2)
- .setInterpolator(new android.view.animation.DecelerateInterpolator())
- .start();
- }).start();
- }
- } else {
- updateViews.run();
+ protected void onSaveInstanceState(android.os.Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (this.navigationController != null) {
+ outState.putInt("savedTab", this.navigationController.getCurrentTabIndex());
}
- }
-
- private void setupCarouselIndicators(int count) {
- if (carouselIndicators == null) return;
- carouselIndicators.removeAllViews();
- for (int i = 0; i < count; i++) {
- android.widget.ImageView dot = new android.widget.ImageView(this);
- dot.setImageDrawable(getDrawable(R.drawable.shape_dot_inactive));
- android.widget.LinearLayout.LayoutParams params = new android.widget.LinearLayout.LayoutParams(16, 16);
- params.setMargins(8, 0, 8, 0);
- dot.setLayoutParams(params);
- carouselIndicators.addView(dot);
+ RadioGroup automationSegmentGroup = (RadioGroup) this.findViewById(R.id.automationSegmentGroup);
+ if (automationSegmentGroup != null) {
+ outState.putInt("savedAutomationSubTab", automationSegmentGroup.getCheckedRadioButtonId());
}
}
- private void updateCarouselIndicators(int position) {
- if (carouselIndicators == null) return;
- for (int i = 0; i < carouselIndicators.getChildCount(); i++) {
- android.widget.ImageView dot = (android.widget.ImageView) carouselIndicators.getChildAt(i);
- if (i == position) {
- dot.setImageDrawable(getDrawable(R.drawable.shape_dot_active));
- } else {
- dot.setImageDrawable(getDrawable(R.drawable.shape_dot_inactive));
- }
+ protected void onResume() {
+ super.onResume();
+ if (this.targetSimUiController != null) {
+ this.targetSimUiController.updateAutoSimWarning();
}
- }
- private void updateSeparatorVisibility() {
- // Execution Mode Separator
- int modeId = radioGroup.getCheckedRadioButtonId();
- if (modeId == -1) {
- separatorRootShizuku.setVisibility(View.VISIBLE);
- } else {
- separatorRootShizuku.setVisibility(View.INVISIBLE);
+ if (this.permissionManager != null) {
+ this.permissionManager.checkAndRequest();
}
-
- // 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);
+ this.updateAuthorizationUI(isExecutionAuthorized());
+ if (this.broadcastTabHelper != null) {
+ this.broadcastTabHelper.refreshCapabilities();
}
- }
-
- private void registerShizukuListeners() {
- Shizuku.addBinderReceivedListener(binderReceivedListener);
- Shizuku.addBinderDeadListener(binderDeadListener);
- Shizuku.addRequestPermissionResultListener(permissionResultListener);
- }
-
- private void loadSavedExecutionMode() {
- ExecutionMode savedMode = appPreferences.getExecutionMode();
- if (savedMode == ExecutionMode.ROOT) {
- radioRoot.setChecked(true);
- checkRootPermission();
- } else if (savedMode == ExecutionMode.SHIZUKU) {
- radioShizuku.setChecked(true);
- checkShizukuPermission(false);
- } else {
- radioGroup.clearCheck();
- setStatus("Select Root or Shizuku mode.", 0xFFFFB300);
+ if (this.shortcutTabHelper != null) {
+ this.shortcutTabHelper.refreshCapabilities();
}
+ if (this.diagnosticUiController != null) this.diagnosticUiController.updateErrorBanner();
}
- private boolean updatingSimUi = false;
-
- private void bindSelectionListeners() {
- radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
- updateSeparatorVisibility();
- if (checkedId == R.id.radioRoot) {
- appPreferences.onExecutionModeChanged(ExecutionMode.ROOT);
- checkRootPermission();
- } else if (checkedId == R.id.radioShizuku) {
- appPreferences.onExecutionModeChanged(ExecutionMode.SHIZUKU);
- checkShizukuPermission(true);
- }
- });
-
- 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();
- });
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- if (appPreferences != null) updateAutoSimWarning();
- checkAndRequestPermission();
- updateErrorBanner();
- updateCapabilities();
-
- }
-
- @Override
protected void onPause() {
super.onPause();
}
- 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);
+ private void onTargetSimSelectionChanged() {
+ TargetSim newTarget = this.appPreferences.getTargetSim();
+ if (this.lastTargetSim != TargetSim.BOTH && newTarget == TargetSim.BOTH) {
+ AppExecutors.executeTelephony(() -> {
+ NetworkMode mode1 = this.modeReader.readCurrentMode(TargetSim.SIM_1);
+ NetworkMode mode2 = this.modeReader.readCurrentMode(TargetSim.SIM_2);
+ if (mode1 != NetworkMode.UNKNOWN && mode2 != NetworkMode.UNKNOWN && mode1 != mode2) {
+ TileCycleManager tileCycleManager = new TileCycleManager(this.appPreferences);
+ AppPreferences.NetworkCapabilities combinedCaps = this.capabilityResolver.getCapabilities(
+ this.appPreferences.getExecutionMode(),
+ TargetSim.BOTH
+ );
+ tileCycleManager.forceRemoveUnsupportedAndAutoFill(combinedCaps);
+ List cycle = tileCycleManager.getCycle();
+ NetworkMode modeToApply = cycle.contains((Object)mode1) ? mode1 : (cycle.contains((Object)mode2) ? mode2 : cycle.get(0));
+
+ AutomationRequest request = new AutomationRequest(
+ modeToApply,
+ TargetSim.BOTH,
+ false,
+ "TargetSimSync",
+ false
+ );
+ AutomationExecutor.execute(getApplicationContext(), request);
}
+ this.updateCapabilities();
});
-
- 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();
- });
+ } else {
+ this.updateCapabilities();
}
-
- diagnosticDialog.show();
+ this.lastTargetSim = newTarget;
}
private void updateCapabilities() {
- new Thread(() -> {
- AppPreferences.NetworkCapabilities caps = capabilityResolver.getCapabilities(appPreferences.getExecutionMode());
- runOnUiThread(() -> {
- if (!activityDestroyed && tileCycleUiController != null) {
- tileCycleUiController.applyCapabilities(caps);
+ AppExecutors.executeTelephony(() -> {
+ AppPreferences.NetworkCapabilities caps = this.capabilityResolver.getCapabilities(this.appPreferences.getExecutionMode());
+ this.runOnUiThread(() -> {
+ if (!this.activityDestroyed && this.tileCycleUiController != null) {
+ this.tileCycleUiController.applyCapabilities(caps);
+ }
+ if (!this.activityDestroyed && this.broadcastTabHelper != null) {
+ this.broadcastTabHelper.refreshCapabilities();
+ }
+ if (!this.activityDestroyed && this.shortcutTabHelper != null) {
+ this.shortcutTabHelper.refreshCapabilities();
}
+ TileService.requestListeningState((Context)this, (ComponentName)new ComponentName((Context)this, NetworkTileService.class));
});
- }).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();
- }
+ if (this.permissionManager != null) {
+ this.permissionManager.handleRequestPermissionsResult(requestCode, grantResults);
}
}
- @Override
protected void onDestroy() {
- activityDestroyed = true;
- if (appPreferences != null) appPreferences.unregisterListener(this);
- Shizuku.removeBinderReceivedListener(binderReceivedListener);
- Shizuku.removeBinderDeadListener(binderDeadListener);
- Shizuku.removeRequestPermissionResultListener(permissionResultListener);
-
- if (rootCheckProcess != null) {
- rootCheckProcess.destroy();
- rootCheckProcess = null;
+ this.activityDestroyed = true;
+ if (this.navigationController != null) {
+ this.navigationController.destroy();
+ }
+ if (this.appPreferences != null) {
+ this.appPreferences.unregisterListener(this);
+ }
+ if (this.executionStateController != null) {
+ this.executionStateController.destroy();
+ }
+ if (this.permissionManager != null) {
+ this.permissionManager.destroy();
+ }
+ if (this.shortcutTabHelper != null) {
+ this.shortcutTabHelper.destroy();
}
- if (rootCheckThread != null && rootCheckThread.isAlive()) {
- rootCheckThread.interrupt();
- rootCheckThread = null;
+ if (this.diagnosticUiController != null) {
+ this.diagnosticUiController.destroy();
}
super.onDestroy();
}
- @Override
- public void onSharedPreferenceChanged(android.content.SharedPreferences sharedPreferences, String key) {
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if ("last_error_cmd".equals(key)) {
- runOnUiThread(() -> updateErrorBanner());
+ this.runOnUiThread(() -> { if (this.diagnosticUiController != null) this.diagnosticUiController.updateErrorBanner(); });
}
}
- private void checkRootPermission() {
- setStatus("Checking root permission...", 0xFFFFB300);
- rootCheckThread = new Thread(() -> {
- boolean granted = false;
- Process process = null;
- try {
- process = Runtime.getRuntime().exec(new String[]{"su", "-c", "id"});
- rootCheckProcess = process;
- granted = process.waitFor() == 0;
- } catch (Exception ignored) {
- granted = false;
- } finally {
- if (process != null) process.destroy();
- if (rootCheckProcess == process) rootCheckProcess = null;
- }
-
- boolean finalGranted = granted;
- runOnUiThread(() -> {
- if (activityDestroyed || appPreferences == null
- || appPreferences.getExecutionMode() != ExecutionMode.ROOT) return;
- 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();
- }
-
- private void checkShizukuPermission(boolean requestIfNeeded) {
- if (activityDestroyed) return;
- 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));
- }
+ private void updateAuthorizationUI(boolean authorized) {
+ if (this.tileCycleUiController != null) {
+ this.tileCycleUiController.setAuthorized(authorized);
}
- }
-
- private void setStatus(String text, int color) {
- statusText.setText(text);
- statusText.setTextColor(color);
-
- if (color == 0xFF1B873F) {
- statusText.setBackgroundResource(
- R.drawable.shape_status_badge_success
- );
- } else if (color == 0xFFFF5555) {
- statusText.setBackgroundResource(
- R.drawable.shape_status_badge_error
- );
- } else {
- statusText.setBackgroundResource(
- R.drawable.shape_pill_badge_bg
- );
- }
- updateAuthorizationUI(color == 0xFF1B873F);
- }
-
-
-
-
-
-
-
-
- private void updateCarouselContext(boolean authorized) {
- if (carouselBackground == null || carouselItems == null) return;
-
- boolean animate = !isFirstCarouselRender;
- isFirstCarouselRender = false;
-
- if (!authorized) {
- if (currentCarouselIndex != 0 || !animate) {
- currentCarouselIndex = 0;
- renderCarouselPage(0, animate, -1);
- }
- } else {
- // Always start at 2nd page (index 1) on auth transition
- if (currentCarouselIndex != 1 || !animate) {
- currentCarouselIndex = 1;
- renderCarouselPage(1, animate, 1);
- }
+ if (this.targetSimUiController != null) {
+ this.targetSimUiController.setAuthorized(authorized);
}
- }
-
- private boolean isUIAuthorized = false;
- private boolean isFirstCarouselRender = true;
-
-
- private void updateAuthorizationUI(boolean authorized) {
- if (isUIAuthorized == authorized) return;
- isUIAuthorized = authorized;
- updateCarouselContext(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() {
- try {
- PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0);
- return info.versionName;
- } catch (Exception e) {
- return "unknown";
+ if (this.broadcastTabHelper != null) {
+ this.broadcastTabHelper.setAuthorized(authorized);
}
- }
-
- private void openUrl(String url) {
- try {
- startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
- } catch (Exception ignored) {
+ if (this.shortcutTabHelper != null) {
+ this.shortcutTabHelper.setAuthorized(authorized);
+ }
+ if (authorized && this.appPreferences != null) {
+ if (this.appPreferences.getDeviceCapabilities() == null) {
+ this.updateCapabilities();
+ }
}
}
- private void loadSavedTargetSimMode() {
- TargetSim target = appPreferences.getTargetSim();
- if (target == TargetSim.SIM_1) radioSim1.setChecked(true);
- else if (target == TargetSim.SIM_2) radioSim2.setChecked(true);
- else radioSimAuto.setChecked(true);
+ public boolean isExecutionAuthorized() {
+ return this.executionModeUiController != null && this.executionModeUiController.isExecutionAuthorized();
}
- private void updateAutoSimWarning() {
- if (autoSimWarningText == null || appPreferences == null) return;
- boolean show = appPreferences.hasAutoSimError()
- && appPreferences.getTargetSim() == TargetSim.AUTO;
- autoSimWarningText.setVisibility(show ? View.VISIBLE : View.GONE);
- if (show) {
- autoSimWarningText.setText(
- "Auto SIM detection failed. Please choose SIM 1 or SIM 2 manually.");
- autoSimWarningText.setTextColor(0xFFFF5555);
- }
- }
}
-
diff --git a/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java b/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
index ddc8a28a..2ae13934 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/NetworkTileService.java
@@ -1,18 +1,16 @@
package com.dhangofa.networktoggle;
-import android.os.Build;
-
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.Typeface;
-import android.graphics.drawable.Icon;
+
+/**
+ * Quick Settings (QS) Tile Service.
+ * This handles the actual toggle button that sits in the Android notification shade.
+ * When tapped, it reads the current network mode, figures out the next mode based on the configured cycle,
+ * and executes the change using the chosen backend (Root/Shizuku).
+ * It also dynamically draws the tile icon to reflect the currently active mode.
+ */
import android.os.Handler;
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;
@@ -25,25 +23,16 @@
import com.dhangofa.networktoggle.telephony.NetworkModeReader;
import com.dhangofa.networktoggle.telephony.SimResolver;
import com.dhangofa.networktoggle.cycle.TileCycleManager;
+import com.dhangofa.networktoggle.util.AppExecutors;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
+import com.dhangofa.networktoggle.ui.TileIconManager;
+
public class NetworkTileService extends TileService {
- private static final ExecutorService EXECUTOR =
- Executors.newSingleThreadExecutor();
private static final AtomicBoolean IS_SWITCHING =
new AtomicBoolean(false);
- private static Icon icon4g;
- 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());
private AppPreferences appPreferences;
@@ -52,6 +41,55 @@ public class NetworkTileService extends TileService {
private TileCycleManager tileCycleManager;
private com.dhangofa.networktoggle.telephony.SimResolver simResolver;
+ private final Runnable shizukuGraceCheckRunnable = () -> {
+ if (appPreferences != null && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ boolean isShizukuOk = false;
+ try {
+ isShizukuOk = Shizuku.pingBinder()
+ && Shizuku.checkSelfPermission() == 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);
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ } else if (isShizukuOk && currentError == AppPreferences.TILE_ERROR_SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ }
+ }
+ };
+
+ private final Shizuku.OnBinderReceivedListener binderReceivedListener = () ->
+ mainHandler.post(() -> {
+ mainHandler.removeCallbacks(shizukuGraceCheckRunnable);
+ if (appPreferences != null && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ boolean isShizukuOk = false;
+ try {
+ isShizukuOk = Shizuku.pingBinder()
+ && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED;
+ } catch (Throwable ignored) {}
+
+ if (isShizukuOk) {
+ if (appPreferences.getTileErrorState() == AppPreferences.TILE_ERROR_SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ }
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ }
+ }
+ });
+
+ private final Shizuku.OnBinderDeadListener binderDeadListener = () ->
+ mainHandler.post(() -> {
+ mainHandler.removeCallbacks(shizukuGraceCheckRunnable);
+ if (appPreferences != null && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ }
+ });
+
@Override
public void onCreate() {
super.onCreate();
@@ -62,38 +100,80 @@ public void onCreate() {
simResolver = new com.dhangofa.networktoggle.telephony.SimResolver(this, appPreferences);
networkModeReader = new com.dhangofa.networktoggle.telephony.NetworkModeReader(this, appPreferences, simResolver);
networkModeController = new NetworkModeController(simResolver);
+
+ try {
+ Shizuku.addBinderReceivedListenerSticky(binderReceivedListener);
+ Shizuku.addBinderDeadListener(binderDeadListener);
+ } catch (Throwable ignored) {}
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mainHandler.removeCallbacksAndMessages(null);
+ try {
+ Shizuku.removeBinderReceivedListener(binderReceivedListener);
+ Shizuku.removeBinderDeadListener(binderDeadListener);
+ } catch (Throwable ignored) {}
+ }
+
+ @Override
+ public void onStopListening() {
+ super.onStopListening();
+ mainHandler.removeCallbacks(shizukuGraceCheckRunnable);
}
@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;
+ isShizukuOk = Shizuku.pingBinder()
+ && Shizuku.checkSelfPermission() == 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);
+ if (isShizukuOk) {
+ if (currentError == AppPreferences.TILE_ERROR_SHIZUKU) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ }
+ } else {
+ // On cold start, Shizuku IPC handshake takes ~20-50ms to bind.
+ // Do not prematurely mark tile as dead; give it a 500ms grace check.
+ mainHandler.removeCallbacks(shizukuGraceCheckRunnable);
+ mainHandler.postDelayed(shizukuGraceCheckRunnable, 500);
}
}
NetworkMode cachedMode = appPreferences.getCachedNetworkMode();
updateTileUI(cachedMode);
- if (appPreferences.getExecutionMode() == ExecutionMode.NONE
- || cachedMode != NetworkMode.UNKNOWN) {
+ if (appPreferences.getExecutionMode() == ExecutionMode.NONE) {
return;
}
- EXECUTOR.execute(() -> {
+ boolean shouldRefresh = (cachedMode == NetworkMode.UNKNOWN);
+ long lastCheck = appPreferences.getLastNetworkCheckTimestamp();
+
+ // Balanced cooldown before passively re-checking modem:
+ // 30 seconds when auto-restore is enabled, 5 minutes for standard passive refresh.
+ // Prevents rapid-fire privileged Binder or shell reads on repeated notification shade pulls.
+ boolean autoRestoreEnabled = appPreferences.isAutoRestorePreferredModeEnabled();
+ long refreshInterval = autoRestoreEnabled ? 30_000L : 5 * 60_000L;
+ if (!shouldRefresh && (System.currentTimeMillis() - lastCheck > refreshInterval)) {
+ shouldRefresh = true;
+ }
+
+ if (!shouldRefresh) {
+ return;
+ }
+
+ AppExecutors.executeTelephony(() -> {
NetworkMode realMode = networkModeReader.readCurrentMode();
mainHandler.post(() -> {
@@ -101,13 +181,28 @@ public void onStartListening() {
* A tile click or another operation may have updated the state
* while this asynchronous readback was running.
*/
- if (appPreferences.getCachedNetworkMode()
- != NetworkMode.UNKNOWN) {
+ long newCheck = appPreferences.getLastNetworkCheckTimestamp();
+ if (newCheck > lastCheck && cachedMode != NetworkMode.UNKNOWN) {
return;
}
if (realMode != NetworkMode.UNKNOWN) {
appPreferences.setCachedNetworkMode(realMode);
+ appPreferences.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+
+ // Auto-restore preferred mode if user enabled it and OS/carrier changed mode
+ NetworkMode preferredMode = appPreferences.getLastUserSelectedMode();
+ if (appPreferences.isAutoRestorePreferredModeEnabled()
+ && preferredMode != NetworkMode.UNKNOWN
+ && realMode != preferredMode
+ && IS_SWITCHING.compareAndSet(false, true)) {
+ updateTileSwitchingUI();
+ AppExecutors.executeTelephony(() -> {
+ applyModeInternal(preferredMode, appPreferences.getExecutionMode(), true);
+ });
+ return;
+ }
+
updateTileUI(realMode);
}
});
@@ -134,11 +229,63 @@ public void onClick() {
NetworkMode nextMode = tileCycleManager.getNextMode(currentMode);
updateTileSwitchingUI();
- EXECUTOR.execute(() -> {
+ AppExecutors.executeTelephony(() -> {
+ applyModeInternal(nextMode, executionMode, false);
+ });
+ }
+
+ private void applyModeInternal(NetworkMode targetMode, ExecutionMode executionMode, boolean isAutoRestore) {
+ CommandResult result;
+
+ // Cold-start binder latch: wait briefly (up to 300ms) for Shizuku binder to attach if needed
+ if (executionMode == ExecutionMode.SHIZUKU && !Shizuku.pingBinder()) {
+ for (int i = 0; i < 6 && !Shizuku.pingBinder(); i++) {
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException ignored) {}
+ }
+ }
+
+ if (appPreferences.getTargetSim() == com.dhangofa.networktoggle.model.TargetSim.BOTH) {
+ SimResolver.SimInfo info1 = simResolver.resolveTargetSimInfo(executionMode, com.dhangofa.networktoggle.model.TargetSim.SIM_1);
+ SimResolver.SimInfo info2 = simResolver.resolveTargetSimInfo(executionMode, com.dhangofa.networktoggle.model.TargetSim.SIM_2);
+
+ if (info1 == null || info2 == null) {
+ mainHandler.post(() -> {
+ Toast.makeText(getApplicationContext(), getString(R.string.toast_no_sim_target_slot), Toast.LENGTH_SHORT).show();
+ appPreferences.onTargetSimChanged(com.dhangofa.networktoggle.model.TargetSim.AUTO);
+ updateTileUI(appPreferences.getCachedNetworkMode());
+ IS_SWITCHING.set(false);
+ });
+ return;
+ }
+
+ CommandResult result1 = CommandResult.failed("", "SIM 1 was not attempted.");
+ CommandResult result2 = CommandResult.failed("", "SIM 2 was not attempted.");
+ try {
+ simResolver.setOverrideTargetSim(com.dhangofa.networktoggle.model.TargetSim.SIM_1);
+ result1 = networkModeController.apply(targetMode, executionMode);
+
+ simResolver.setOverrideTargetSim(com.dhangofa.networktoggle.model.TargetSim.SIM_2);
+ result2 = networkModeController.apply(targetMode, executionMode);
+ } finally {
+ simResolver.setOverrideTargetSim(null);
+ }
+
+ if (result1.isSuccess() && result2.isSuccess()) {
+ result = CommandResult.completed("", 0, "Applied to both SIMs", "");
+ } else if (result1.isSuccess()) {
+ result = CommandResult.failed("", "Failed to apply to SIM 2. Err: " + result2.getStderr());
+ } else if (result2.isSuccess()) {
+ result = CommandResult.failed("", "Failed to apply to SIM 1. Err: " + result1.getStderr());
+ } else {
+ result = result1;
+ }
+ } else {
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();
+ Toast.makeText(getApplicationContext(), getString(R.string.toast_no_sim_target_slot), Toast.LENGTH_SHORT).show();
appPreferences.onTargetSimChanged(com.dhangofa.networktoggle.model.TargetSim.AUTO);
updateTileUI(appPreferences.getCachedNetworkMode());
IS_SWITCHING.set(false);
@@ -146,117 +293,72 @@ public void onClick() {
return;
}
- CommandResult result = networkModeController.apply(
- nextMode,
+ result = networkModeController.apply(
+ targetMode,
executionMode
);
+ }
- 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) {
+ if (result.isSuccess()) {
+ appPreferences.setCachedNetworkMode(targetMode);
+ if (!isAutoRestore) {
+ appPreferences.setLastUserSelectedMode(targetMode);
+ }
+ appPreferences.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ appPreferences.setAutoSimError(false);
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ mainHandler.post(() -> {
+ updateTileUI(targetMode);
+ 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);
}
- } 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) {
+ } 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);
}
+ }
- if (!isAuthError) {
- appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
- appPreferences.setLastError(result.getCommand(), result.getExitCode(), result.getStdout(), result.getStderr(), result.getExceptionMessage());
- }
-
- mainHandler.post(() -> {
- updateTileUI(currentMode);
- if (appPreferences.hasAutoSimError()) {
- showAutoSimErrorToast();
+ if (!isAuthError) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ String exceptionMsg = result.getExceptionMessage();
+ if (isAutoRestore) {
+ if (exceptionMsg != null && !exceptionMsg.isEmpty()) {
+ exceptionMsg = exceptionMsg + " (Source: Auto Restore)";
+ } else {
+ exceptionMsg = "Source: Auto Restore";
}
- IS_SWITCHING.set(false);
- });
+ }
+ appPreferences.setLastError(result.getCommand(), result.getExitCode(), result.getStdout(), result.getStderr(), exceptionMsg);
}
- });
- }
-
- private Icon createTextOnlyIcon(String text) {
- int size = 256;
- Bitmap bitmap = Bitmap.createBitmap(
- size,
- size,
- Bitmap.Config.ARGB_8888
- );
- Canvas canvas = new Canvas(bitmap);
- Paint paint = new Paint();
- paint.setAntiAlias(true);
- paint.setColor(Color.WHITE);
- paint.setTypeface(Typeface.create(
- "sans-serif-condensed",
- Typeface.BOLD
- ));
- paint.setTextAlign(Paint.Align.CENTER);
- paint.setTextSize(190f);
-
- float width = paint.measureText(text);
- if (width > 240f) {
- paint.setTextScaleX(240f / width);
- }
- Paint.FontMetrics metrics = paint.getFontMetrics();
- float y = (size / 2f) - (metrics.descent + metrics.ascent) / 2f;
- canvas.drawText(text, size / 2f, y, paint);
-
- return Icon.createWithBitmap(bitmap);
- }
-
- private Icon getCachedIcon(String text) {
- switch (text) {
- case "4G":
- if (icon4g == null) icon4g = createTextOnlyIcon("4G");
- return icon4g;
- case "5G":
- if (icon5g == null) icon5g = createTextOnlyIcon("5G");
- return icon5g;
- case "P5G":
- if (iconP5g == null) iconP5g = createTextOnlyIcon("P5G");
- return iconP5g;
- 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("?");
+ NetworkMode fallbackMode = appPreferences.getCachedNetworkMode();
+ mainHandler.post(() -> {
+ updateTileUI(fallbackMode);
+ if (appPreferences.hasAutoSimError()) {
+ showAutoSimErrorToast();
}
- return iconUnknown;
+ IS_SWITCHING.set(false);
+ });
}
}
@@ -265,8 +367,8 @@ private void updateTileSwitchingUI() {
if (tile == null) return;
tile.setState(Tile.STATE_INACTIVE);
- tile.setLabel("Switching...");
- tile.setIcon(getCachedIcon("?"));
+ tile.setLabel(getString(R.string.tile_switching));
+ tile.setIcon(TileIconManager.getCachedIcon("?", "", false));
tile.updateTile();
}
@@ -279,21 +381,32 @@ private void updateTileUI(NetworkMode mode) {
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;
+ boolean isActuallyOk = false;
+ try {
+ isActuallyOk = Shizuku.pingBinder()
+ && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED;
+ } catch (Throwable ignored) {}
+
+ if (isActuallyOk) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ errorState = AppPreferences.TILE_ERROR_NONE;
+ } else {
+ tile.setState(Tile.STATE_UNAVAILABLE);
+ tile.setLabel(getString(R.string.tile_shizuku_unavailable));
+ tile.setIcon(TileIconManager.getCachedIcon("?", "", false));
+ tile.updateTile();
+ return;
+ }
} else if (errorState == AppPreferences.TILE_ERROR_ROOT) {
tile.setState(Tile.STATE_UNAVAILABLE);
- tile.setLabel("Root Unavailable");
- tile.setIcon(getCachedIcon("?"));
+ tile.setLabel(getString(R.string.tile_root_unavailable));
+ tile.setIcon(TileIconManager.getCachedIcon("?", "", false));
tile.updateTile();
return;
} else if (errorState == AppPreferences.TILE_ERROR_CMD) {
tile.setState(Tile.STATE_INACTIVE);
- tile.setLabel("Error Check App");
- tile.setIcon(getCachedIcon("?"));
+ tile.setLabel(getString(R.string.tile_error_check_app));
+ tile.setIcon(TileIconManager.getCachedIcon("?", "", false));
tile.updateTile();
return;
}
@@ -301,31 +414,49 @@ private void updateTileUI(NetworkMode mode) {
if (mode == NetworkMode.UNKNOWN) {
if (appPreferences.getExecutionMode() == ExecutionMode.NONE) {
tile.setState(Tile.STATE_UNAVAILABLE);
- tile.setLabel("Setup Required");
+ tile.setLabel(getString(R.string.tile_setup_required));
} else {
NetworkMode firstMode = tileCycleManager.getFirstMode();
tile.setState(Tile.STATE_INACTIVE);
- tile.setLabel("Tap to Set " + firstMode.getTileLabel());
+ tile.setLabel(getString(R.string.tile_tap_to_set, firstMode.getTileLabel()));
}
- tile.setIcon(getCachedIcon("?"));
+ tile.setIcon(TileIconManager.getCachedIcon("?", "", false));
} else {
tile.setState(Tile.STATE_ACTIVE);
tile.setLabel(mode.getTileLabel());
- tile.setIcon(getCachedIcon(mode.getIconText()));
+
+ // Determine badge and auto state
+ com.dhangofa.networktoggle.model.TargetSim targetSim = appPreferences.getTargetSim();
+ String badge = "";
+ boolean isAuto = targetSim == com.dhangofa.networktoggle.model.TargetSim.AUTO;
+
+ if (isAuto) {
+ int activeSlot = simResolver.resolveTargetSlotIndex(appPreferences.getExecutionMode());
+ if (simResolver.isValidSlotIndex(activeSlot)) {
+ badge = String.valueOf(activeSlot + 1);
+ } else {
+ badge = "";
+ }
+ } else if (targetSim == com.dhangofa.networktoggle.model.TargetSim.SIM_1) {
+ badge = "1";
+ } else if (targetSim == com.dhangofa.networktoggle.model.TargetSim.SIM_2) {
+ badge = "2";
+ } else if (targetSim == com.dhangofa.networktoggle.model.TargetSim.BOTH) { // Future proofing for BOTH
+ badge = "+";
+ }
+
+ tile.setIcon(TileIconManager.getCachedIcon(mode.getIconText(), badge, isAuto));
}
tile.updateTile();
}
-
-
private void showAutoSimErrorToast() {
Toast.makeText(
this,
- "Unable to detect active data SIM automatically. "
- + "Please choose SIM from the app.",
+ getString(R.string.toast_auto_sim_failed),
Toast.LENGTH_LONG
).show();
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ShortcutActionActivity.java b/app/src/main/java/com/dhangofa/networktoggle/ShortcutActionActivity.java
new file mode 100644
index 00000000..5774640e
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ShortcutActionActivity.java
@@ -0,0 +1,56 @@
+package com.dhangofa.networktoggle;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import com.dhangofa.networktoggle.automation.AutomationExecutor;
+import com.dhangofa.networktoggle.automation.AutomationRequest;
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+import com.dhangofa.networktoggle.util.AppExecutors;
+
+public class ShortcutActionActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Intent intent = getIntent();
+ if (intent != null) {
+ String mode = intent.getStringExtra("mode");
+ int sim = intent.getIntExtra("sim", -1);
+
+ if (mode != null) {
+ TargetSim targetSim = TargetSim.AUTO;
+ if (sim == 1) targetSim = TargetSim.SIM_1;
+ else if (sim == 2) targetSim = TargetSim.SIM_2;
+ else if (sim == 3) targetSim = TargetSim.BOTH;
+ else {
+ com.dhangofa.networktoggle.config.AppPreferences prefs = new com.dhangofa.networktoggle.config.AppPreferences(this);
+ targetSim = prefs.getTargetSim();
+ }
+
+ NetworkMode targetMode = NetworkMode.fromString(mode);
+ if (targetMode != NetworkMode.UNKNOWN) {
+ final TargetSim finalTargetSim = targetSim;
+ AppExecutors.executeTelephony(() -> {
+ AutomationRequest request = new AutomationRequest(targetMode, finalTargetSim, false, "Shortcut");
+ AutomationExecutor.execute(getApplicationContext(), request);
+ });
+ }
+
+ String displayMode = mode.replace("_ONLY", " Only").replace("PREF_", "Pref ");
+ String target = "";
+ if (sim == 1) target = " on SIM 1";
+ else if (sim == 2) target = " on SIM 2";
+ else if (sim == 3) target = " on Both SIMs";
+
+ Toast.makeText(getApplicationContext(), getString(R.string.toast_applying) + " " + displayMode + target + "...", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ finish();
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationExecutor.java b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationExecutor.java
new file mode 100644
index 00000000..4b062ebd
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationExecutor.java
@@ -0,0 +1,206 @@
+package com.dhangofa.networktoggle.automation;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+import com.dhangofa.networktoggle.telephony.NetworkModeController;
+import com.dhangofa.networktoggle.telephony.SimResolver;
+import com.dhangofa.networktoggle.NetworkTileService;
+import android.service.quicksettings.TileService;
+import android.content.ComponentName;
+
+public class AutomationExecutor {
+ private static final String TAG = "AutomationExecutor";
+
+ public static AutomationResult execute(Context context, AutomationRequest request) {
+ AppPreferences prefs = new AppPreferences(context);
+ ExecutionMode execMode = prefs.getExecutionMode();
+
+ if (execMode == ExecutionMode.NONE) {
+ String err = "Rejected: Execution mode is NONE.";
+ Log.e(TAG, err);
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ return new AutomationResult(false, false, err, request.mode, request.target, false, false);
+ }
+
+ if (request.external && !prefs.isExternalAutomationEnabled()) {
+ String err = "Rejected: External automation is disabled.";
+ Log.e(TAG, err);
+ return new AutomationResult(false, false, err, request.mode, request.target, false, false);
+ }
+
+ SimResolver simResolver = new SimResolver(context, prefs);
+ TargetSim targetSim = request.target;
+ if (targetSim == TargetSim.AUTO) {
+ // resolve auto directly
+ simResolver.setOverrideTargetSim(TargetSim.AUTO);
+ SimResolver.SimInfo info = simResolver.resolveTargetSimInfo(execMode);
+ if (info == null || info.slotIndex < 0) {
+ String err = "Rejected: Auto SIM resolution failed.";
+ Log.e(TAG, err);
+ prefs.setAutoSimError(true);
+ saveFailure(prefs, null, err);
+ simResolver.setOverrideTargetSim(null);
+ return new AutomationResult(false, false, err, request.mode, TargetSim.AUTO, false, false);
+ }
+ targetSim = info.slotIndex == 0 ? TargetSim.SIM_1 : TargetSim.SIM_2;
+ simResolver.setOverrideTargetSim(null);
+ }
+
+ AppPreferences.NetworkCapabilities caps;
+ if (targetSim == TargetSim.BOTH) {
+ AppPreferences.NetworkCapabilities sim1Caps = prefs.getSlotCapabilities(0);
+ AppPreferences.NetworkCapabilities sim2Caps = prefs.getSlotCapabilities(1);
+ if (sim1Caps == null) sim1Caps = prefs.getDeviceCapabilities();
+ if (sim2Caps == null) sim2Caps = prefs.getDeviceCapabilities();
+
+ if (sim1Caps == null || sim2Caps == null) {
+ caps = AppPreferences.NetworkCapabilities.assumeAll();
+ } else {
+ caps = new AppPreferences.NetworkCapabilities(
+ sim1Caps.supports2g && sim2Caps.supports2g,
+ sim1Caps.supports3g && sim2Caps.supports3g,
+ sim1Caps.supports4g && sim2Caps.supports4g,
+ sim1Caps.supports5g && sim2Caps.supports5g
+ );
+ }
+ } else {
+ int slotIndex = targetSim.getManualSlotIndex();
+ caps = prefs.getSlotCapabilities(slotIndex);
+ if (caps == null) caps = prefs.getDeviceCapabilities();
+ if (caps == null) caps = AppPreferences.NetworkCapabilities.assumeAll();
+ }
+
+ boolean supported = false;
+ switch (request.mode) {
+ case FIVE_G_ONLY:
+ case PREFERRED_5G:
+ supported = caps.supports5g;
+ break;
+ case FOUR_G_ONLY:
+ case PREFERRED_4G:
+ supported = caps.supports4g;
+ break;
+ case PREFERRED_3G:
+ supported = caps.supports3g;
+ break;
+ case TWO_G_ONLY:
+ supported = caps.supports2g;
+ break;
+ default:
+ supported = true;
+ }
+
+ if (!supported) {
+ String err = "Rejected requested mode " + request.mode.getDisplayName() + " as it is not supported by the selected SIM(s).";
+ Log.e(TAG, err);
+ saveFailure(prefs, null, err);
+ return new AutomationResult(false, false, err, request.mode, targetSim, false, false);
+ }
+
+ NetworkModeController controller = new NetworkModeController(simResolver);
+ boolean success = false;
+ boolean isPartial = false;
+ boolean sim1Success = false;
+ boolean sim2Success = false;
+ String errorMessage = "";
+
+ try {
+ if (targetSim == TargetSim.BOTH) {
+ SimResolver.SimInfo info1 = simResolver.resolveTargetSimInfo(execMode, TargetSim.SIM_1);
+ SimResolver.SimInfo info2 = simResolver.resolveTargetSimInfo(execMode, TargetSim.SIM_2);
+
+ if (info1 == null || info2 == null) {
+ errorMessage = "Rejected: One or both SIMs are not available.";
+ Log.e(TAG, errorMessage);
+ saveFailure(prefs, null, errorMessage);
+ return new AutomationResult(false, false, errorMessage, request.mode, targetSim, false, false);
+ }
+
+ CommandResult r1 = CommandResult.failed("", "SIM 1 was not attempted.");
+ CommandResult r2 = CommandResult.failed("", "SIM 2 was not attempted.");
+
+ simResolver.setOverrideTargetSim(TargetSim.SIM_1);
+ r1 = controller.apply(request.mode, execMode);
+ simResolver.setOverrideTargetSim(TargetSim.SIM_2);
+ r2 = controller.apply(request.mode, execMode);
+
+ sim1Success = r1.isSuccess();
+ sim2Success = r2.isSuccess();
+
+ if (r1.isSuccess() && r2.isSuccess()) {
+ success = true;
+ } else if (r1.isSuccess() || r2.isSuccess()) {
+ isPartial = true;
+ errorMessage = "Partial success. SIM 1: " + r1.isSuccess() + ", SIM 2: " + r2.isSuccess() +
+ " | SIM 1 err: " + r1.getStderr() + " | SIM 2 err: " + r2.getStderr();
+ Log.e(TAG, errorMessage);
+ CommandResult failedResult = r1.isSuccess() ? r2 : r1;
+ saveFailure(prefs, failedResult, errorMessage);
+ } else {
+ errorMessage = "Failed on both SIMs. SIM 1 err: " + r1.getStderr() + " | SIM 2 err: " + r2.getStderr();
+ Log.e(TAG, errorMessage);
+ saveFailure(prefs, r1, errorMessage);
+ }
+ } else {
+ simResolver.setOverrideTargetSim(targetSim);
+ SimResolver.SimInfo info = simResolver.resolveTargetSimInfo(execMode);
+ if (info == null || info.slotIndex != targetSim.getManualSlotIndex()) {
+ errorMessage = "Rejected: Requested SIM " + (targetSim.getManualSlotIndex() + 1) + " is not available or removed.";
+ Log.e(TAG, errorMessage);
+ saveFailure(prefs, null, errorMessage);
+ return new AutomationResult(false, false, errorMessage, request.mode, targetSim, false, false);
+ }
+
+ CommandResult result = controller.apply(request.mode, execMode);
+ boolean isSlot0 = targetSim == TargetSim.SIM_1;
+ if (result.isSuccess()) {
+ success = true;
+ if (isSlot0) {
+ sim1Success = true;
+ } else {
+ sim2Success = true;
+ }
+ } else {
+ errorMessage = "Failed to change network mode via automation: " + result.getStderr();
+ Log.e(TAG, errorMessage);
+ saveFailure(prefs, result, errorMessage);
+ }
+ }
+
+ if (success) {
+ prefs.setCachedNetworkMode(request.mode);
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ prefs.setAutoSimError(false);
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ if (request.updatePreferredMode) {
+ prefs.setLastUserSelectedMode(request.mode);
+ }
+ } else if (isPartial) {
+ prefs.setCachedNetworkMode(com.dhangofa.networktoggle.model.NetworkMode.UNKNOWN);
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ } else {
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ }
+ } finally {
+ simResolver.setOverrideTargetSim(null);
+ TileService.requestListeningState(context, new ComponentName(context, NetworkTileService.class));
+ }
+
+ return new AutomationResult(success, isPartial, errorMessage, request.mode, targetSim, sim1Success, sim2Success);
+ }
+
+ private static void saveFailure(AppPreferences prefs, CommandResult result, String context) {
+ if (result != null) {
+ prefs.setLastError(result.getCommand(), result.getExitCode(), result.getStdout(), result.getStderr(), context);
+ } else {
+ prefs.setLastError("", -1, "", context, context);
+ }
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationRequest.java b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationRequest.java
new file mode 100644
index 00000000..47366475
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationRequest.java
@@ -0,0 +1,24 @@
+package com.dhangofa.networktoggle.automation;
+
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+
+public class AutomationRequest {
+ public final NetworkMode mode;
+ public final TargetSim target;
+ public final boolean external;
+ public final String source;
+ public final boolean updatePreferredMode;
+
+ public AutomationRequest(NetworkMode mode, TargetSim target, boolean external, String source) {
+ this(mode, target, external, source, true);
+ }
+
+ public AutomationRequest(NetworkMode mode, TargetSim target, boolean external, String source, boolean updatePreferredMode) {
+ this.mode = mode;
+ this.target = target;
+ this.external = external;
+ this.source = source;
+ this.updatePreferredMode = updatePreferredMode;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationResult.java b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationResult.java
new file mode 100644
index 00000000..9a03df30
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/automation/AutomationResult.java
@@ -0,0 +1,30 @@
+package com.dhangofa.networktoggle.automation;
+
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+
+public class AutomationResult {
+ public final boolean success;
+ public final boolean isPartial;
+ public final String errorMessage;
+ public final NetworkMode requestedMode;
+ public final TargetSim requestedTarget;
+ public final boolean sim1Success;
+ public final boolean sim2Success;
+
+ public AutomationResult(boolean success, boolean isPartial, String errorMessage) {
+ this(success, isPartial, errorMessage, null, null, success, success);
+ }
+
+ public AutomationResult(boolean success, boolean isPartial, String errorMessage,
+ NetworkMode requestedMode, TargetSim requestedTarget,
+ boolean sim1Success, boolean sim2Success) {
+ this.success = success;
+ this.isPartial = isPartial;
+ this.errorMessage = errorMessage;
+ this.requestedMode = requestedMode;
+ this.requestedTarget = requestedTarget;
+ this.sim1Success = sim1Success;
+ this.sim2Success = sim2Success;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutor.java b/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutor.java
index 66cbeab5..6c8a3726 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutor.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutor.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Interface for executing shell commands.
+ * Provides a common contract to swap between Root and Shizuku implementations seamlessly.
+ */
+
import com.dhangofa.networktoggle.model.CommandResult;
public interface CommandExecutor {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutorFactory.java b/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutorFactory.java
index 150a4d37..b69603bb 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutorFactory.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/CommandExecutorFactory.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Factory class used to create the right CommandExecutor.
+ * It looks at the user's preferred execution mode (Root vs Shizuku) and returns the corresponding executor.
+ */
+
import com.dhangofa.networktoggle.model.ExecutionMode;
public final class CommandExecutorFactory {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/ProcessResultReader.java b/app/src/main/java/com/dhangofa/networktoggle/command/ProcessResultReader.java
index 3397b815..8a2e9c3d 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/ProcessResultReader.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/ProcessResultReader.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Helper to consume standard output and error streams from a process.
+ * This prevents deadlocks that can happen if the output buffer fills up before it is read.
+ */
+
import com.dhangofa.networktoggle.model.CommandResult;
final class ProcessResultReader {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/RootCommandExecutor.java b/app/src/main/java/com/dhangofa/networktoggle/command/RootCommandExecutor.java
index 4d535963..6f1c6780 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/RootCommandExecutor.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/RootCommandExecutor.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Implementation of CommandExecutor that uses standard su (Root) privileges.
+ * It spawns a root shell, runs the command, and collects the output via ProcessResultReader.
+ */
+
import com.dhangofa.networktoggle.model.CommandResult;
public final class RootCommandExecutor implements CommandExecutor {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/ShizukuCommandExecutor.java b/app/src/main/java/com/dhangofa/networktoggle/command/ShizukuCommandExecutor.java
index bce624a8..07141e2d 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/ShizukuCommandExecutor.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/ShizukuCommandExecutor.java
@@ -1,5 +1,11 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Implementation of CommandExecutor that relies on Shizuku for elevated privileges.
+ * This is great for users who don't have a rooted device but want to use ADB-level commands.
+ * It hooks into the Shizuku Binder to execute the shell command.
+ */
+
import android.content.pm.PackageManager;
import com.dhangofa.networktoggle.model.CommandResult;
import java.lang.reflect.Method;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/command/StreamCollector.java b/app/src/main/java/com/dhangofa/networktoggle/command/StreamCollector.java
index efaca66f..d31f9ff1 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/command/StreamCollector.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/command/StreamCollector.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.command;
+/**
+ * Utility class used internally by ProcessResultReader to asynchronously read an InputStream
+ * into a String. It runs on a separate thread to keep things non-blocking.
+ */
+
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
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 d56dd311..0afcec31 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/config/AppPreferences.java
@@ -1,5 +1,12 @@
package com.dhangofa.networktoggle.config;
+/**
+ * Central configuration and state manager.
+ * Reads and writes all the SharedPreferences for the app, including which execution mode
+ * (Root/Shizuku) is selected, target SIM, saved network cycles, and any transient error states.
+ * This acts as the single source of truth for app settings.
+ */
+
import android.content.Context;
import android.content.SharedPreferences;
@@ -15,18 +22,23 @@ public final class AppPreferences {
private static final String KEY_EXEC_MODE = "exec_mode";
private static final String KEY_TARGET_SIM = "target_sim";
private static final String KEY_NETWORK_STATE = "net_state";
+ private static final String KEY_LAST_NETWORK_CHECK = "last_network_check";
private static final String KEY_AUTO_SIM_ERROR = "auto_sim_error";
+ private static final String KEY_EXTERNAL_AUTOMATION = "external_automation_enabled";
+ private static final String KEY_AUTOMATION_TOKEN = "automation_token";
private static final String KEY_TILE_CYCLE_MODES = "tile_cycle_modes";
-
+ private static final String KEY_AUTO_RESTORE_ENABLED = "auto_restore_enabled";
+ private static final String KEY_LAST_USER_SELECTED_MODE = "last_user_selected_mode";
+
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;
@@ -79,6 +91,54 @@ public void clearCachedNetworkMode() {
setCachedNetworkMode(NetworkMode.UNKNOWN);
}
+ public long getLastNetworkCheckTimestamp() {
+ return preferences.getLong(KEY_LAST_NETWORK_CHECK, 0);
+ }
+
+ public boolean isExternalAutomationEnabled() {
+ return preferences.getBoolean(KEY_EXTERNAL_AUTOMATION, false);
+ }
+
+ public void setExternalAutomationEnabled(boolean enabled) {
+ preferences.edit().putBoolean(KEY_EXTERNAL_AUTOMATION, enabled).apply();
+ }
+
+ public boolean isAutoRestorePreferredModeEnabled() {
+ return preferences.getBoolean(KEY_AUTO_RESTORE_ENABLED, false);
+ }
+
+ public void setAutoRestorePreferredModeEnabled(boolean enabled) {
+ preferences.edit().putBoolean(KEY_AUTO_RESTORE_ENABLED, enabled).apply();
+ }
+
+ public NetworkMode getLastUserSelectedMode() {
+ int val = preferences.getInt(KEY_LAST_USER_SELECTED_MODE, NetworkMode.UNKNOWN.getStateValue());
+ return NetworkMode.fromStateValue(val);
+ }
+
+ public void setLastUserSelectedMode(NetworkMode mode) {
+ if (mode != null && mode != NetworkMode.UNKNOWN) {
+ preferences.edit().putInt(KEY_LAST_USER_SELECTED_MODE, mode.getStateValue()).apply();
+ }
+ }
+
+ public String getAutomationToken() {
+ String token = preferences.getString(KEY_AUTOMATION_TOKEN, "");
+ if (token == null || token.trim().isEmpty()) {
+ token = java.util.UUID.randomUUID().toString().replace("-", "");
+ preferences.edit().putString(KEY_AUTOMATION_TOKEN, token).apply();
+ }
+ return token;
+ }
+
+ public void setAutomationToken(String token) {
+ preferences.edit().putString(KEY_AUTOMATION_TOKEN, token).apply();
+ }
+
+ public void setLastNetworkCheckTimestamp(long timestamp) {
+ preferences.edit().putLong(KEY_LAST_NETWORK_CHECK, timestamp).apply();
+ }
+
public boolean hasAutoSimError() {
return preferences.getBoolean(KEY_AUTO_SIM_ERROR, false);
}
@@ -92,10 +152,10 @@ public void setLastError(String command, int exitCode, String stdout, String std
.putString(KEY_LAST_ERROR_CMD, command)
.putInt("last_error_exit_code", exitCode)
.putString("last_error_stdout", stdout)
-
+
.putString(KEY_LAST_ERROR_STDERR, stderr)
.putString("last_error_exception", exceptionMsg)
-
+
.putLong(KEY_LAST_ERROR_TIMESTAMP, System.currentTimeMillis())
.apply();
}
@@ -107,7 +167,7 @@ public com.dhangofa.networktoggle.model.DiagnosticError getLastError() {
String stdout = preferences.getString("last_error_stdout", "");
String exceptionMsg = preferences.getString("last_error_exception", "");
long time = preferences.getLong(KEY_LAST_ERROR_TIMESTAMP, 0);
-
+
if (cmd == null && stderr == null) return null;
return new com.dhangofa.networktoggle.model.DiagnosticError(cmd, exitCode, stdout, stderr, exceptionMsg, time);
}
@@ -118,7 +178,10 @@ public void clearLastError() {
}
preferences.edit()
.remove(KEY_LAST_ERROR_CMD)
+ .remove("last_error_exit_code")
+ .remove("last_error_stdout")
.remove(KEY_LAST_ERROR_STDERR)
+ .remove("last_error_exception")
.remove(KEY_LAST_ERROR_TIMESTAMP)
.apply();
}
@@ -239,7 +302,7 @@ public NetworkCapabilities getSlotCapabilities(int slotIndex) {
preferences.getBoolean(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g", true)
);
}
-
+
public void clearDeviceCapabilities() {
preferences.edit()
.remove(KEY_DEVICE_CAPS_PREFIX + "2g")
@@ -258,4 +321,44 @@ public void invalidateSlotCache(int slotIndex) {
.remove(KEY_SLOT_CAPS_PREFIX + slotIndex + "_5g")
.apply();
}
+
+ public void saveRoutineShortcut(int slot, String mode, int sim) {
+ saveRoutineShortcut(slot, getRoutineShortcutName(slot), mode, sim);
+ }
+
+ public void saveRoutineShortcut(int slot, String name, String mode, int sim) {
+ preferences.edit()
+ .putString("routine_shortcut_name_" + slot, name != null ? name : "")
+ .putString("routine_shortcut_mode_" + slot, mode)
+ .putInt("routine_shortcut_sim_" + slot, sim)
+ .apply();
+ }
+
+ public String getRoutineShortcutName(int slot) {
+ return preferences.getString("routine_shortcut_name_" + slot, "");
+ }
+
+ public String getRoutineShortcutMode(int slot) {
+ return preferences.getString("routine_shortcut_mode_" + slot, "NONE");
+ }
+
+ public int getRoutineShortcutSim(int slot) {
+ return preferences.getInt("routine_shortcut_sim_" + slot, 1); // 1 = SIM 1
+ }
+
+ public boolean isRoutineShortcutsInitialized() {
+ return preferences.getBoolean("routine_shortcuts_initialized", false);
+ }
+
+ public void setRoutineShortcutsInitialized(boolean initialized) {
+ preferences.edit().putBoolean("routine_shortcuts_initialized", initialized).apply();
+ }
+
+ public boolean hasRequestedPhonePermission() {
+ return preferences.getBoolean("phone_permission_requested", false);
+ }
+
+ public void setPhonePermissionRequested(boolean requested) {
+ preferences.edit().putBoolean("phone_permission_requested", requested).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 2c007d11..2dea1328 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/cycle/TileCycleManager.java
@@ -1,5 +1,11 @@
package com.dhangofa.networktoggle.cycle;
+/**
+ * This handles the logic for the "cycle" of network modes.
+ * When tapping the Quick Settings tile, this class is used to figure out what the next mode
+ * in the sequence should be based on preferences.
+ */
+
import com.dhangofa.networktoggle.config.AppPreferences;
import com.dhangofa.networktoggle.model.NetworkMode;
@@ -79,15 +85,15 @@ public boolean forceRemoveUnsupportedAndAutoFill(AppPreferences.NetworkCapabilit
List cycle = getCycle();
boolean changed = false;
- if (!caps.supports5g) {
- changed |= cycle.remove(NetworkMode.PREFERRED_5G);
- changed |= cycle.remove(NetworkMode.FIVE_G_ONLY);
+ 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.supports3g) {
+ changed |= cycle.remove(NetworkMode.PREFERRED_3G);
}
- if (!caps.supports2g) {
- changed |= cycle.remove(NetworkMode.TWO_G_ONLY);
+ if (!caps.supports2g) {
+ changed |= cycle.remove(NetworkMode.TWO_G_ONLY);
}
if (changed) {
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/CommandResult.java b/app/src/main/java/com/dhangofa/networktoggle/model/CommandResult.java
index 1997515e..0532aea1 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/CommandResult.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/CommandResult.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.model;
+/**
+ * Wrapper to pass back the results of shell commands (stdout, stderr, exit code).
+ * Helps easily check if a command succeeded or failed.
+ */
+
public final class CommandResult {
private final String command;
private final int exitCode;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java b/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java
index f040ac80..71c66ec3 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/DiagnosticError.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.model;
+/**
+ * Tracks diagnostic errors (like Shizuku Binder death, or Root permission denied)
+ * to display meaningful error banners in the app.
+ */
+
public class DiagnosticError {
public final String command;
public final int exitCode;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/ExecutionMode.java b/app/src/main/java/com/dhangofa/networktoggle/model/ExecutionMode.java
index 3270e68e..72b31f71 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/ExecutionMode.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/ExecutionMode.java
@@ -1,5 +1,9 @@
package com.dhangofa.networktoggle.model;
+/**
+ * Enum representing how the app to execute commands (Root, Shizuku, or None/Not setup yet).
+ */
+
public enum ExecutionMode {
NONE(0),
ROOT(1),
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 42573f39..ffeb0e47 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/NetworkMode.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.model;
+/**
+ * This enum maps the different network types (like 5G, 4G, 3G) to their internal Android telephony IDs.
+ * Translates requested modes into the raw values the `cmd phone` commands expect.
+ */
+
public enum NetworkMode {
UNKNOWN(0, "Unknown", "Tap to Set 4G", "?", null, -1),
FOUR_G_ONLY(1, "4G Only", "4G Only", "4G", "1000000000000", 11),
@@ -89,4 +94,32 @@ public static NetworkMode fromLegacyMode(Integer legacyMode) {
return UNKNOWN;
}
}
+
+ public static NetworkMode fromString(String modeString) {
+ if (modeString == null) return UNKNOWN;
+ modeString = modeString.toUpperCase().trim();
+ switch (modeString) {
+ case "5G_ONLY":
+ case "5G":
+ return FIVE_G_ONLY;
+ case "4G_ONLY":
+ case "4G":
+ case "LTE":
+ return FOUR_G_ONLY;
+ case "PREF_5G":
+ case "PREFERRED_5G":
+ return PREFERRED_5G;
+ case "PREF_4G":
+ case "PREFERRED_4G":
+ return PREFERRED_4G;
+ case "PREF_3G":
+ case "PREFERRED_3G":
+ return PREFERRED_3G;
+ case "2G_ONLY":
+ case "2G":
+ return TWO_G_ONLY;
+ default:
+ return UNKNOWN;
+ }
+ }
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/model/TargetSim.java b/app/src/main/java/com/dhangofa/networktoggle/model/TargetSim.java
index cfdfffa7..c99360a7 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/model/TargetSim.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/model/TargetSim.java
@@ -1,9 +1,14 @@
package com.dhangofa.networktoggle.model;
+/**
+ * Enum representing which SIM slot the app should target (SIM 1, SIM 2, or Auto).
+ */
+
public enum TargetSim {
AUTO(0, -1),
SIM_1(1, 0),
- SIM_2(2, 1);
+ SIM_2(2, 1),
+ BOTH(3, -1);
private final int value;
private final int manualSlotIndex;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyNetworkModeController.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootModeController.java
similarity index 87%
rename from app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyNetworkModeController.java
rename to app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootModeController.java
index 30c6be62..e0206e1f 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyNetworkModeController.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootModeController.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * Changes the network mode on older Android versions using root.
+ * It essentially builds the legacy payload and executes the root command.
+ */
+
import android.content.Context;
import com.dhangofa.networktoggle.command.CommandExecutor;
import com.dhangofa.networktoggle.command.CommandExecutorFactory;
@@ -7,11 +12,11 @@
import com.dhangofa.networktoggle.model.ExecutionMode;
import com.dhangofa.networktoggle.model.NetworkMode;
-final class LegacyNetworkModeController {
+final class LegacyRootModeController {
private final Context context;
private final SimResolver simResolver;
- LegacyNetworkModeController(Context context, SimResolver simResolver) {
+ LegacyRootModeController(Context context, SimResolver simResolver) {
this.context = context.getApplicationContext();
this.simResolver = simResolver;
}
@@ -27,22 +32,22 @@ CommandResult apply(NetworkMode networkMode, ExecutionMode executionMode) {
|| !simResolver.isValidSubId(simInfo.subId)) {
return CommandResult.failed("", "Unable to resolve the target SIM.");
}
-
+
String apkPath = context.getApplicationInfo().sourceDir;
- String className = "com.dhangofa.networktoggle.telephony.NetworkModePayload";
-
+ String className = "com.dhangofa.networktoggle.telephony.LegacyRootPayload";
+
String command = "CLASSPATH=\"" + apkPath + "\" app_process /system/bin " + className + " " + simInfo.subId + " " + networkMode.getLegacyMode();
-
+
CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
if (executor == null) {
return CommandResult.failed(command, "No execution mode selected.");
}
-
+
CommandResult result = executor.execute(command);
if (!result.isSuccess()) {
return result;
}
-
+
return CommandResult.completed(command, 0,
"Legacy network mode applied successfully via Java payload.", "");
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModePayload.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootPayload.java
similarity index 90%
rename from app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModePayload.java
rename to app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootPayload.java
index 397337da..1e7ed4a1 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModePayload.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/LegacyRootPayload.java
@@ -1,12 +1,17 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * Helper to build the binary payload required by the older `service call phone` commands.
+ * Generating the exact byte sequence is tricky, so this encapsulates the logic.
+ */
+
import android.annotation.SuppressLint;
import android.os.IBinder;
import androidx.annotation.Keep;
import java.lang.reflect.Method;
@Keep
-public class NetworkModePayload {
+public class LegacyRootPayload {
@Keep
public static void main(String[] args) {
try {
@@ -16,30 +21,30 @@ public static void main(String[] args) {
}
int subId = Integer.parseInt(args[0]);
int networkMode = Integer.parseInt(args[1]);
-
+
@SuppressLint("PrivateApi")
Class> serviceManagerClass = Class.forName("android.os.ServiceManager");
Method getServiceMethod = serviceManagerClass.getDeclaredMethod("getService", String.class);
IBinder binder = (IBinder) getServiceMethod.invoke(null, "phone");
-
+
if (binder == null) {
System.err.println("Failed to get 'phone' service binder.");
System.exit(1);
}
-
+
@SuppressLint("PrivateApi")
Class> iTelephonyStubClass = Class.forName("com.android.internal.telephony.ITelephony$Stub");
Method asInterfaceMethod = iTelephonyStubClass.getDeclaredMethod("asInterface", IBinder.class);
Object iTelephony = asInterfaceMethod.invoke(null, binder);
-
+
if (iTelephony == null) {
System.err.println("Failed to get ITelephony interface.");
System.exit(1);
}
-
+
Method setPrefNetworkTypeMethod = iTelephony.getClass().getMethod("setPreferredNetworkType", int.class, int.class);
Object result = setPrefNetworkTypeMethod.invoke(iTelephony, subId, networkMode);
-
+
if (result instanceof Boolean && !((Boolean) result)) {
System.err.println("ITelephony returned false.");
System.exit(1);
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java
index add4fa93..f31cfc43 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/LteAndAboveCarrierRegistry.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * A registry of carriers that require LTE as a minimum.
+ * Prevents users from dropping to 3G/2G on networks that no longer support it (like Verizon).
+ */
+
import java.util.Arrays;
import java.util.List;
@@ -69,7 +74,7 @@ public static boolean isLteAndAboveOnly(String carrierName) {
private static boolean matchesCarrier(String carrierName, List carrierAliases) {
for (String alias : carrierAliases) {
String normalizedAlias = alias.toLowerCase().trim();
-
+
if (carrierName.equals(normalizedAlias)) {
return true;
}
@@ -82,18 +87,18 @@ private static boolean matchesCarrier(String carrierName, List carrierAl
return true;
}
}
-
+
return false;
}
-
+
private static boolean containsWholePhrase(String carrierName, String alias) {
String normalizedCarrier = normalizeSeparators(carrierName);
String normalizedAlias = normalizeSeparators(alias);
-
+
return (" " + normalizedCarrier + " ")
.contains(" " + normalizedAlias + " ");
}
-
+
private static String normalizeSeparators(String value) {
return value.replace('-', ' ')
.replace('_', ' ')
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/ModernRootModeController.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/ModernRootModeController.java
new file mode 100644
index 00000000..45672ffb
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/ModernRootModeController.java
@@ -0,0 +1,43 @@
+package com.dhangofa.networktoggle.telephony;
+
+/**
+ * Changes the network mode on modern Android versions using root.
+ * It utilizes the newer `cmd phone` utility which is much cleaner than the old service calls.
+ */
+
+import com.dhangofa.networktoggle.command.CommandExecutor;
+import com.dhangofa.networktoggle.command.CommandExecutorFactory;
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.NetworkMode;
+
+final class ModernRootModeController {
+ private final SimResolver simResolver;
+
+ ModernRootModeController(SimResolver simResolver) {
+ this.simResolver = simResolver;
+ }
+
+ CommandResult apply(NetworkMode networkMode, ExecutionMode executionMode) {
+ if (networkMode == null || networkMode == NetworkMode.UNKNOWN
+ || networkMode.getBinaryMask() == null) {
+ return CommandResult.failed("", "Invalid network mode selected.");
+ }
+
+ int slotIndex = simResolver.resolveTargetSlotIndex(executionMode);
+ if (!simResolver.isValidSlotIndex(slotIndex)) {
+ return CommandResult.failed("",
+ "Unable to resolve the target physical SIM slot.");
+ }
+
+ String command = "cmd phone set-allowed-network-types-for-users -s "
+ + slotIndex + " " + networkMode.getBinaryMask();
+
+ CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
+ if (executor == null) {
+ return CommandResult.failed(command, "No execution mode selected.");
+ }
+
+ return executor.execute(command);
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java
index 3de893b6..cdc81664 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkCapabilityResolver.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * Resolves what network types (5G, 4G, etc.) the phone actually supports.
+ * It parses raw baseband properties to decide which toggle options should be available in the UI.
+ */
+
import android.os.Build;
import com.dhangofa.networktoggle.command.CommandExecutor;
@@ -16,7 +21,7 @@ public final class NetworkCapabilityResolver {
private static final class CarrierCapabilityResult {
final NetworkCapabilities capabilities;
final boolean cacheable;
-
+
CarrierCapabilityResult(NetworkCapabilities capabilities, boolean cacheable) {
this.capabilities = capabilities;
this.cacheable = cacheable;
@@ -29,10 +34,30 @@ public NetworkCapabilityResolver(AppPreferences appPreferences, SimResolver simR
}
public NetworkCapabilities getCapabilities(ExecutionMode mode) {
+ return getCapabilities(mode, appPreferences.getTargetSim());
+ }
+
+ public NetworkCapabilities getCapabilities(ExecutionMode mode, com.dhangofa.networktoggle.model.TargetSim targetSim) {
if (mode == ExecutionMode.NONE) return NetworkCapabilities.assumeAll();
+ if (targetSim == com.dhangofa.networktoggle.model.TargetSim.BOTH) {
+ NetworkCapabilities caps1 = getSingleSimCapabilities(mode, com.dhangofa.networktoggle.model.TargetSim.SIM_1);
+ NetworkCapabilities caps2 = getSingleSimCapabilities(mode, com.dhangofa.networktoggle.model.TargetSim.SIM_2);
+
+ return new NetworkCapabilities(
+ caps1.supports2g && caps2.supports2g,
+ caps1.supports3g && caps2.supports3g,
+ caps1.supports4g && caps2.supports4g,
+ caps1.supports5g && caps2.supports5g
+ );
+ }
+
+ return getSingleSimCapabilities(mode, targetSim);
+ }
+
+ private NetworkCapabilities getSingleSimCapabilities(ExecutionMode mode, com.dhangofa.networktoggle.model.TargetSim targetSim) {
// ONE SINGLE CALL to get slotIndex, subId, and carrierName!
- SimResolver.SimInfo simInfo = simResolver.resolveTargetSimInfo(mode);
+ SimResolver.SimInfo simInfo = simResolver.resolveTargetSimInfo(mode, targetSim);
if (simInfo == null || !simResolver.isValidSlotIndex(simInfo.slotIndex) || !simResolver.isValidSubId(simInfo.subId)) {
return NetworkCapabilities.assumeAll();
@@ -55,10 +80,10 @@ public NetworkCapabilities getCapabilities(ExecutionMode mode) {
// 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)
CarrierCapabilityResult carrierResult = fetchCarrierCapabilities(mode, slotIndex, carrierName);
NetworkCapabilities carrierCaps = carrierResult.capabilities;
@@ -79,19 +104,19 @@ public NetworkCapabilities getCapabilities(ExecutionMode mode) {
private NetworkCapabilities fetchDeviceCapabilities(ExecutionMode mode) {
NetworkCapabilities cachedDevice = appPreferences.getDeviceCapabilities();
-
+
if (cachedDevice != null) {
return cachedDevice;
}
-
+
// 5G capability detection starts from Android 11.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
NetworkCapabilities legacyCapabilities = new NetworkCapabilities(true, true, true, false);
-
+
appPreferences.saveDeviceCapabilities(legacyCapabilities);
return legacyCapabilities;
}
-
+
CommandExecutor executor = CommandExecutorFactory.forMode(mode);
if (executor == null) {
return NetworkCapabilities.assumeAll();
@@ -101,12 +126,12 @@ private NetworkCapabilities fetchDeviceCapabilities(ExecutionMode mode) {
return NetworkCapabilities.assumeAll();
}
String output = result.getStdout();
-
+
// Empty output does not prove that 5G is unsupported.
if (output == null || output.trim().isEmpty()) {
return NetworkCapabilities.assumeAll();
}
-
+
boolean foundValidProfile = false;
boolean supports5g = false;
String[] values = output.trim().split(",");
@@ -122,12 +147,12 @@ private NetworkCapabilities fetchDeviceCapabilities(ExecutionMode mode) {
break;
}
}
-
+
// Do not cache a result when the property had no numeric profile.
if (!foundValidProfile) {
return NetworkCapabilities.assumeAll();
}
-
+
NetworkCapabilities deviceCapabilities = new NetworkCapabilities(true, true, true, supports5g);
appPreferences.saveDeviceCapabilities(deviceCapabilities);
return deviceCapabilities;
@@ -139,14 +164,14 @@ private CarrierCapabilityResult fetchCarrierCapabilities(
String carrierName
) {
CommandExecutor executor = CommandExecutorFactory.forMode(mode);
-
+
if (executor == null) {
return new CarrierCapabilityResult(
NetworkCapabilities.assumeAll(),
false
);
}
-
+
// Stage 3: Carrier Config XML verification.
String command =
"dumpsys carrier_config | grep -E " +
@@ -156,82 +181,82 @@ private CarrierCapabilityResult fetchCarrierCapabilities(
"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;
boolean foundCarrierSetting = false;
-
+
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;
continue;
}
-
+
if (!inTargetSlot) {
continue;
}
-
+
if (trimmed.startsWith("hide_enable_2g_bool =")) {
foundCarrierSetting = true;
-
+
if (trimmed.endsWith("true")) {
supports2g = false;
}
}
-
+
if (trimmed.startsWith("carrier_supports_2g_bool =")) {
foundCarrierSetting = true;
-
+
if (trimmed.endsWith("false")) {
supports2g = false;
}
}
-
+
if (trimmed.startsWith("hide_enable_3g_bool =")) {
foundCarrierSetting = true;
-
+
if (trimmed.endsWith("true")) {
supports3g = false;
}
}
-
+
if (trimmed.startsWith("carrier_supports_3g_bool =")) {
foundCarrierSetting = true;
-
+
if (trimmed.endsWith("false")) {
supports3g = false;
}
}
-
+
if (trimmed.startsWith("carrier_nr_availabilities_int_array =")) {
foundCarrierSetting = true;
-
+
if (trimmed.endsWith("[]")) {
supports5g = false;
}
}
}
}
-
+
// Stage 4: maintained real-world carrier restrictions.
boolean registryMatched =
LteAndAboveCarrierRegistry.isLteAndAboveOnly(carrierName);
-
+
if (registryMatched) {
supports2g = false;
supports3g = false;
}
-
+
NetworkCapabilities capabilities =
new NetworkCapabilities(
supports2g,
@@ -239,9 +264,9 @@ private CarrierCapabilityResult fetchCarrierCapabilities(
true,
supports5g
);
-
+
boolean cacheable = foundCarrierSetting || registryMatched;
-
+
return new CarrierCapabilityResult(capabilities, cacheable);
}
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeController.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeController.java
index 52bb8440..4de9c6a3 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeController.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeController.java
@@ -1,47 +1,44 @@
package com.dhangofa.networktoggle.telephony;
-import android.os.Build;
+/**
+ * Main entry point for changing network modes.
+ * It figures out whether to use Root or Shizuku, and whether to use the Modern or Legacy root methods
+ * based on the device API level.
+ */
-import com.dhangofa.networktoggle.command.CommandExecutor;
-import com.dhangofa.networktoggle.command.CommandExecutorFactory;
+import android.os.Build;
import com.dhangofa.networktoggle.model.CommandResult;
import com.dhangofa.networktoggle.model.ExecutionMode;
import com.dhangofa.networktoggle.model.NetworkMode;
public final class NetworkModeController {
- private final SimResolver simResolver;
- private final LegacyNetworkModeController legacyController;
+ private final ShizukuBinderModeController shizukuBinderController;
+ private final LegacyRootModeController legacyController;
+ private final ModernRootModeController modernRootController;
public NetworkModeController(SimResolver simResolver) {
- this.simResolver = simResolver;
- this.legacyController = new LegacyNetworkModeController(
+ this.shizukuBinderController = new ShizukuBinderModeController(simResolver);
+ this.legacyController = new LegacyRootModeController(
simResolver.getContext(), simResolver);
+ this.modernRootController = new ModernRootModeController(simResolver);
}
public CommandResult apply(NetworkMode networkMode, ExecutionMode executionMode) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
- return legacyController.apply(networkMode, executionMode);
+ if (executionMode == null || executionMode == ExecutionMode.NONE) {
+ return CommandResult.failed("", "No execution mode selected.");
}
- if (networkMode == null || networkMode == NetworkMode.UNKNOWN
- || networkMode.getBinaryMask() == null) {
- return CommandResult.failed("", "Invalid network mode selected.");
+ // 1. Shizuku Fast-Path (Binder IPC)
+ if (executionMode == ExecutionMode.SHIZUKU) {
+ return shizukuBinderController.apply(networkMode, executionMode);
}
- int slotIndex = simResolver.resolveTargetSlotIndex(executionMode);
- if (!simResolver.isValidSlotIndex(slotIndex)) {
- return CommandResult.failed("",
- "Unable to resolve the target physical SIM slot.");
- }
-
- String command = "cmd phone set-allowed-network-types-for-users -s "
- + slotIndex + " " + networkMode.getBinaryMask();
-
- CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
- if (executor == null) {
- return CommandResult.failed(command, "No execution mode selected.");
+ // 2. Root Legacy Fallback (Android 11 and below)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return legacyController.apply(networkMode, executionMode);
}
- return executor.execute(command);
+ // 3. Root Modern Fallback (Android 12+)
+ return modernRootController.apply(networkMode, executionMode);
}
}
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 18978325..f2254204 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/NetworkModeReader.java
@@ -1,91 +1,56 @@
package com.dhangofa.networktoggle.telephony;
-import com.dhangofa.networktoggle.command.CommandExecutor;
-import com.dhangofa.networktoggle.command.CommandExecutorFactory;
+/** Main entry point for reading the current active network mode. */
+import android.content.Context;
import com.dhangofa.networktoggle.config.AppPreferences;
-import com.dhangofa.networktoggle.model.CommandResult;
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;
- }
-
- public NetworkMode readCurrentMode() {
- ExecutionMode executionMode = appPreferences.getExecutionMode();
- if (executionMode == ExecutionMode.NONE) {
- return NetworkMode.UNKNOWN;
- }
-
- 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"
- + targetSubId
- + "); if [ -n \"$value\" ] "
- + "&& [ \"$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}";
- } else {
- return NetworkMode.UNKNOWN;
- }
-
- CommandResult result = execute(executionMode, command);
- if (!result.isSuccess()) {
- return NetworkMode.UNKNOWN;
- }
-
- return NetworkMode.fromLegacyMode(
- ShellValueParser.extractFirstInt(result.getStdout())
- );
- }
-
- 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);
- }
+ private final AppPreferences appPreferences;
+ private final ShizukuBinderModeReader shizukuBinderReader;
+ private final PrivilegedModeReader privilegedModeReader;
+
+ public NetworkModeReader(Context context, AppPreferences appPreferences, SimResolver simResolver) {
+ this.appPreferences = appPreferences;
+ this.shizukuBinderReader = new ShizukuBinderModeReader(simResolver);
+ this.privilegedModeReader = new PrivilegedModeReader(context, simResolver);
+ }
+
+ public NetworkMode readCurrentMode() {
+ return readCurrentMode(appPreferences.getTargetSim());
+ }
+
+ public NetworkMode readCurrentMode(TargetSim targetSim) {
+ ExecutionMode executionMode = appPreferences.getExecutionMode();
+ if (executionMode == ExecutionMode.NONE) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ if (targetSim == TargetSim.BOTH) {
+ NetworkMode mode1 = readCurrentMode(TargetSim.SIM_1);
+ NetworkMode mode2 = readCurrentMode(TargetSim.SIM_2);
+
+ return mode1 != NetworkMode.UNKNOWN && mode1 == mode2
+ ? mode1
+ : NetworkMode.UNKNOWN;
+ }
+
+ return readSingleMode(executionMode, targetSim);
+ }
+
+ private NetworkMode readSingleMode(ExecutionMode executionMode, TargetSim targetSim) {
+ NetworkMode mode = NetworkMode.UNKNOWN;
+ // 1. Shizuku Fast-Path (Binder IPC)
+ if (executionMode == ExecutionMode.SHIZUKU) {
+ mode = shizukuBinderReader.readCurrentMode(executionMode, targetSim);
+ }
+
+ // 2. Root/Shizuku privileged shell fallback path
+ if (mode == NetworkMode.UNKNOWN) {
+ mode = privilegedModeReader.readCurrentMode(executionMode, targetSim);
+ }
+ return mode;
+ }
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/PrivilegedModeReader.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/PrivilegedModeReader.java
new file mode 100644
index 00000000..d28f95fc
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/PrivilegedModeReader.java
@@ -0,0 +1,63 @@
+package com.dhangofa.networktoggle.telephony;
+
+/**
+ * Parses the current network mode through native Settings where supported,
+ * then the selected Root or Shizuku privileged shell fallback.
+ */
+import android.content.Context;
+import android.os.Build;
+import android.provider.Settings;
+import com.dhangofa.networktoggle.command.CommandExecutor;
+import com.dhangofa.networktoggle.command.CommandExecutorFactory;
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+
+final class PrivilegedModeReader {
+ private final Context context;
+ private final SimResolver simResolver;
+
+ PrivilegedModeReader(Context context, SimResolver simResolver) {
+ this.context = context;
+ this.simResolver = simResolver;
+ }
+
+ NetworkMode readCurrentMode(ExecutionMode executionMode, TargetSim targetSim) {
+ int targetSubId = targetSim != null
+ ? simResolver.resolveTargetSubId(executionMode, targetSim)
+ : simResolver.resolveTargetSubId(executionMode);
+
+ // NATIVE API FAST-PATH:
+ // Only works reliably on Android 9 (Pie) and below. Android 10+ will throw SecurityException.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && 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" + targetSubId + "); if [ -n \"$value\" ] && [ \"$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}";
+ } else {
+ return NetworkMode.UNKNOWN;
+ }
+
+ CommandExecutor executor = CommandExecutorFactory.forMode(executionMode);
+ if (executor == null) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ CommandResult result = executor.execute(command);
+ return result.isSuccess() ? NetworkMode.fromLegacyMode(ShellValueParser.extractFirstInt(result.getStdout())) : NetworkMode.UNKNOWN;
+ }
+}
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 296a6c95..8fc27c1a 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShellValueParser.java
@@ -1,5 +1,10 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * Utility to parse the raw text output from the `cmd phone` shell commands
+ * back into the NetworkMode enum.
+ */
+
import java.util.regex.Matcher;
import java.util.regex.Pattern;
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeController.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeController.java
new file mode 100644
index 00000000..8647e16d
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeController.java
@@ -0,0 +1,183 @@
+package com.dhangofa.networktoggle.telephony;
+
+/**
+ * Changes the network mode using the Shizuku Binder.
+ * It creates a direct binder call to the Android telephony service (ITelephony) to bypass
+ * the need for root completely.
+ */
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.IBinder;
+
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.NetworkMode;
+
+import java.lang.reflect.Method;
+
+import rikka.shizuku.ShizukuBinderWrapper;
+import rikka.shizuku.SystemServiceHelper;
+
+final class ShizukuBinderModeController {
+
+ private static final String PACKAGE = "com.dhangofa.networktoggle";
+ private final SimResolver simResolver;
+
+ ShizukuBinderModeController(SimResolver simResolver) {
+ this.simResolver = simResolver;
+ }
+
+ CommandResult apply(NetworkMode networkMode, ExecutionMode executionMode) {
+ if (executionMode != ExecutionMode.SHIZUKU) {
+ return CommandResult.failed("", "Invalid execution mode for Shizuku binder.");
+ }
+
+ if (networkMode == null || networkMode == NetworkMode.UNKNOWN) {
+ return CommandResult.failed("", "Invalid network mode selected.");
+ }
+
+ SimResolver.SimInfo simInfo = simResolver.resolveTargetSimInfo(executionMode);
+
+ if (simInfo == null || !simResolver.isValidSlotIndex(simInfo.slotIndex) || !simResolver.isValidSubId(simInfo.subId)) {
+ return CommandResult.failed("", "Unable to resolve the target SIM.");
+ }
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ org.lsposed.hiddenapibypass.HiddenApiBypass.addHiddenApiExemptions("Lcom/android/internal/telephony/");
+ }
+
+ IBinder raw = SystemServiceHelper.getSystemService("phone");
+ if (raw == null || !raw.pingBinder()) {
+ return CommandResult.failed("AIDL: phone service", "Android phone service Binder is unavailable.");
+ }
+
+ IBinder binder = new ShizukuBinderWrapper(raw);
+ if (!binder.pingBinder()) {
+ return CommandResult.failed("AIDL: phone service", "Shizuku phone service Binder is not responding.");
+ }
+
+ @SuppressLint("PrivateApi")
+ Class> stub = Class.forName("com.android.internal.telephony.ITelephony$Stub");
+ Object phone = stub.getDeclaredMethod("asInterface", IBinder.class).invoke(null, binder);
+
+ if (phone == null) {
+ return CommandResult.failed("", "Failed to get ITelephony interface via Shizuku.");
+ }
+
+ Class> api = Class.forName("com.android.internal.telephony.ITelephony");
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (networkMode.getBinaryMask() == null) {
+ return CommandResult.failed("", "Invalid network mode mask for A12+.");
+ }
+
+ long mask = Long.parseLong(networkMode.getBinaryMask(), 2);
+ Method method = TelephonyMethodHelper.find(
+ api,
+ "setAllowedNetworkTypesForReason",
+ new Class>[] {int.class, int.class, long.class},
+ new Class>[] {int.class, int.class, long.class, String.class},
+ new Class>[] {int.class, long.class}
+ );
+
+ if (method == null) {
+ return CommandResult.failed("AIDL: setAllowedNetworkTypesForReason", "No compatible method signature found in ITelephony.");
+ }
+
+ Object result = method.getParameterCount() == 3
+ ? method.invoke(phone, simInfo.subId, 0, mask)
+ : method.getParameterCount() == 4
+ ? method.invoke(phone, simInfo.subId, 0, mask, PACKAGE)
+ : method.invoke(phone, simInfo.subId, mask);
+
+ if (result instanceof Boolean && !((Boolean) result)) {
+ return CommandResult.failed("AIDL: setAllowedNetworkTypesForReason", "ITelephony returned false.");
+ }
+
+ triggerUiRefreshNudge(simInfo.subId);
+ return CommandResult.completed("AIDL: setAllowedNetworkTypesForReason", 0, "Network mode applied successfully via Shizuku Binder.", "");
+ }
+
+ if (networkMode.getLegacyMode() < 0) {
+ return CommandResult.failed("", "Invalid legacy network mode.");
+ }
+
+ Method method = TelephonyMethodHelper.find(
+ api,
+ "setPreferredNetworkType",
+ new Class>[] {int.class, int.class},
+ new Class>[] {int.class, int.class, String.class}
+ );
+
+ if (method == null) {
+ return CommandResult.failed("AIDL: setPreferredNetworkType", "No compatible method signature found in ITelephony.");
+ }
+
+ Object result = method.getParameterCount() == 2
+ ? method.invoke(phone, simInfo.subId, networkMode.getLegacyMode())
+ : method.invoke(phone, simInfo.subId, networkMode.getLegacyMode(), PACKAGE);
+
+ if (result instanceof Boolean && !((Boolean) result)) {
+ return CommandResult.failed("AIDL: setPreferredNetworkType", "ITelephony returned false.");
+ }
+
+ triggerUiRefreshNudge(simInfo.subId);
+ return CommandResult.completed("AIDL: setPreferredNetworkType", 0, "Legacy network mode applied successfully via Shizuku Binder.", "");
+
+ } catch (Throwable throwable) {
+ return CommandResult.failed("AIDL Binder Call", TelephonyMethodHelper.describe(throwable));
+ }
+ }
+
+ /**
+ * Directly pings the telephony.registry Binder to force SystemUI to redraw the network icons.
+ * This bypasses OEM caching delays (e.g. Samsung/ColorOS 5G icon smoothing) with zero CPU overhead.
+ */
+ private void triggerUiRefreshNudge(int subId) {
+ try {
+ IBinder rawRegistry = SystemServiceHelper.getSystemService("telephony.registry");
+ if (rawRegistry == null || !rawRegistry.pingBinder()) {
+ return;
+ }
+
+ IBinder registryBinder = new ShizukuBinderWrapper(rawRegistry);
+
+ @SuppressLint("PrivateApi")
+ Class> stub = Class.forName("com.android.internal.telephony.ITelephonyRegistry$Stub");
+ Object registry = stub.getDeclaredMethod("asInterface", IBinder.class).invoke(null, registryBinder);
+ if (registry == null) {
+ return;
+ }
+
+ Method notifyCarrier = TelephonyMethodHelper.find(
+ registry.getClass(),
+ "notifyCarrierNetworkChange",
+ new Class>[] {boolean.class}
+ );
+
+ if (notifyCarrier == null) {
+ notifyCarrier = TelephonyMethodHelper.find(
+ registry.getClass(),
+ "notifyCarrierNetworkChangeWithSubId",
+ new Class>[] {int.class, boolean.class}
+ );
+ }
+
+ if (notifyCarrier != null) {
+ if (notifyCarrier.getParameterCount() == 2) {
+ notifyCarrier.invoke(registry, subId, true);
+ Thread.sleep(50);
+ notifyCarrier.invoke(registry, subId, false);
+ } else {
+ notifyCarrier.invoke(registry, true);
+ Thread.sleep(50);
+ notifyCarrier.invoke(registry, false);
+ }
+ }
+ } catch (Throwable ignored) {
+ // Nudge is strictly a best-effort UX improvement. Safely ignore failures.
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeReader.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeReader.java
new file mode 100644
index 00000000..f8ab3624
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/ShizukuBinderModeReader.java
@@ -0,0 +1,134 @@
+package com.dhangofa.networktoggle.telephony;
+
+/**
+ * Reads the current network mode using the Shizuku Binder.
+ * Like the controller, it makes direct binder calls to ITelephony to read the state without root.
+ */
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.IBinder;
+
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.NetworkMode;
+
+import java.lang.reflect.Method;
+
+import rikka.shizuku.ShizukuBinderWrapper;
+import rikka.shizuku.SystemServiceHelper;
+
+final class ShizukuBinderModeReader {
+
+ private static final String PACKAGE = "com.dhangofa.networktoggle";
+ private final SimResolver simResolver;
+
+ ShizukuBinderModeReader(SimResolver simResolver) {
+ this.simResolver = simResolver;
+ }
+
+ NetworkMode readCurrentMode(ExecutionMode executionMode) {
+ return readCurrentMode(executionMode, null);
+ }
+
+ NetworkMode readCurrentMode(ExecutionMode executionMode, com.dhangofa.networktoggle.model.TargetSim targetSim) {
+ if (executionMode != ExecutionMode.SHIZUKU) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ int subId = targetSim != null
+ ? simResolver.resolveTargetSubId(executionMode, targetSim)
+ : simResolver.resolveTargetSubId(executionMode);
+
+ if (!simResolver.isValidSubId(subId)) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ org.lsposed.hiddenapibypass.HiddenApiBypass.addHiddenApiExemptions("Lcom/android/internal/telephony/");
+ }
+
+ IBinder raw = SystemServiceHelper.getSystemService("phone");
+ if (raw == null || !raw.pingBinder()) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ IBinder binder = new ShizukuBinderWrapper(raw);
+ if (!binder.pingBinder()) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ @SuppressLint("PrivateApi")
+ Class> stub = Class.forName("com.android.internal.telephony.ITelephony$Stub");
+ Object phone = stub.getDeclaredMethod("asInterface", IBinder.class).invoke(null, binder);
+
+ Class> api = Class.forName("com.android.internal.telephony.ITelephony");
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ Method method = TelephonyMethodHelper.find(
+ api,
+ "getAllowedNetworkTypesForReason",
+ new Class>[] {int.class, int.class},
+ new Class>[] {int.class, int.class, String.class},
+ new Class>[] {int.class}
+ );
+
+ if (method == null) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ Object value = method.getParameterCount() == 2
+ ? method.invoke(phone, subId, 0)
+ : method.getParameterCount() == 3
+ ? method.invoke(phone, subId, 0, PACKAGE)
+ : method.invoke(phone, subId);
+
+ if (!(value instanceof Number)) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ long bitmask = ((Number) value).longValue();
+
+ for (NetworkMode mode : NetworkMode.values()) {
+ if (mode.getBinaryMask() != null && Long.parseLong(mode.getBinaryMask(), 2) == bitmask) {
+ return mode;
+ }
+ }
+
+ if ((bitmask & (1L << 19)) != 0) {
+ return (bitmask & (1L << 12)) == 0 ? NetworkMode.FIVE_G_ONLY : NetworkMode.PREFERRED_5G;
+ }
+
+ if ((bitmask & (1L << 12)) != 0) {
+ return (bitmask & (1L << 13)) == 0 && (bitmask & (1L << 9)) == 0
+ ? NetworkMode.FOUR_G_ONLY
+ : NetworkMode.PREFERRED_4G;
+ }
+
+ return NetworkMode.PREFERRED_3G;
+ }
+
+ Method method = TelephonyMethodHelper.find(
+ api,
+ "getPreferredNetworkType",
+ new Class>[] {int.class},
+ new Class>[] {int.class, String.class}
+ );
+
+ if (method == null) {
+ return NetworkMode.UNKNOWN;
+ }
+
+ Object value = method.getParameterCount() == 1
+ ? method.invoke(phone, subId)
+ : method.invoke(phone, subId, PACKAGE);
+
+ return value instanceof Number
+ ? NetworkMode.fromLegacyMode(((Number) value).intValue())
+ : NetworkMode.UNKNOWN;
+
+ } catch (Throwable ignored) {
+ return NetworkMode.UNKNOWN;
+ }
+ }
+}
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 c1380864..46629991 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/SimResolver.java
@@ -1,5 +1,11 @@
package com.dhangofa.networktoggle.telephony;
+/**
+ * Helper to manage multi-SIM devices.
+ * It handles the logic for resolving "Auto" SIM to the active data slot, and finding the correct
+ * subId (subscription ID) and phoneId for a given physical SIM slot.
+ */
+
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
@@ -22,6 +28,8 @@ public final class SimResolver {
private final Context context;
private final AppPreferences appPreferences;
+ private volatile TargetSim overrideTargetSim = null;
+
// Data class to hold all extracted variables in one place
public static class SimInfo {
public final int subId;
@@ -43,9 +51,27 @@ public SimResolver(Context context, AppPreferences appPreferences) {
Context getContext() {
return context;
}
-
+
+ public void setOverrideTargetSim(TargetSim targetSim) {
+ this.overrideTargetSim = targetSim;
+ }
+
+ public TargetSim getOverrideTargetSim() {
+ return overrideTargetSim;
+ }
+
public SimInfo resolveTargetSimInfo(ExecutionMode executionMode) {
- TargetSim targetSim = appPreferences.getTargetSim();
+ TargetSim targetSim = overrideTargetSim != null ? overrideTargetSim : appPreferences.getTargetSim();
+ return resolveTargetSimInfo(executionMode, targetSim);
+ }
+
+ public SimInfo resolveTargetSimInfo(ExecutionMode executionMode, TargetSim targetSim) {
+ if (targetSim == null) {
+ targetSim = overrideTargetSim != null ? overrideTargetSim : appPreferences.getTargetSim();
+ }
+ if (targetSim == TargetSim.BOTH) {
+ return null;
+ }
int targetSubId = INVALID_SUB_ID;
int targetSlotIndex = INVALID_SLOT_INDEX;
String carrierName = "";
@@ -139,6 +165,16 @@ public int resolveTargetSubId(ExecutionMode executionMode) {
return info != null ? info.subId : INVALID_SUB_ID;
}
+ public int resolveTargetSubId(ExecutionMode executionMode, TargetSim targetSim) {
+ SimInfo info = resolveTargetSimInfo(executionMode, targetSim);
+ return info != null ? info.subId : INVALID_SUB_ID;
+ }
+
+ public int resolveTargetSlotIndex(ExecutionMode executionMode, TargetSim targetSim) {
+ SimInfo info = resolveTargetSimInfo(executionMode, targetSim);
+ return info != null ? info.slotIndex : INVALID_SLOT_INDEX;
+ }
+
public boolean isValidSlotIndex(int slotIndex) {
return slotIndex == 0 || slotIndex == 1;
}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/telephony/TelephonyMethodHelper.java b/app/src/main/java/com/dhangofa/networktoggle/telephony/TelephonyMethodHelper.java
new file mode 100644
index 00000000..a968b17c
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/telephony/TelephonyMethodHelper.java
@@ -0,0 +1,74 @@
+package com.dhangofa.networktoggle.telephony;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Utility class designed to resolve hidden Android API fragmentation across different device OEMs.
+ * It provides safe reflection methods to locate varying ITelephony method signatures and
+ * unwrap nested reflection exceptions for clearer diagnostic logging.
+ */
+final class TelephonyMethodHelper {
+ private TelephonyMethodHelper() {}
+
+ /**
+ * Attempts to find a method within a given class by iterating through a prioritized list
+ * of potential method signatures. This gracefully handles OEM-specific framework modifications
+ * (e.g., Samsung or Xiaomi appending extra parameters like a calling package string).
+ *
+ * @param type The class to inspect (e.g., ITelephony.class).
+ * @param name The exact string name of the target method.
+ * @param signatures A varargs array of potential parameter type arrays to check, in order of preference.
+ * @return The matching Method object, or null if no compatible signature is found.
+ */
+ static Method find(Class> type, String name, Class>[]... signatures) {
+ for (Class>[] signature : signatures) {
+ for (Method method : type.getDeclaredMethods()) {
+ if (!name.equals(method.getName())) {
+ continue;
+ }
+
+ Class>[] actual = method.getParameterTypes();
+ if (actual.length != signature.length) {
+ continue;
+ }
+
+ boolean match = true;
+ for (int i = 0; i < actual.length; i++) {
+ if (actual[i] != signature[i]) {
+ match = false;
+ break;
+ }
+ }
+
+ if (match) {
+ return method;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Unwraps reflection exceptions to extract the true underlying cause.
+ * Reflection failures often bury the actual error (like a SecurityException) inside an
+ * InvocationTargetException. This method drills down to the root Throwable to generate
+ * readable error messages for diagnostics.
+ *
+ * @param throwable The exception caught during reflection.
+ * @return A clean string describing the root cause class name and message.
+ */
+ static String describe(Throwable throwable) {
+ Throwable cause = throwable;
+ while (cause instanceof InvocationTargetException && ((InvocationTargetException) cause).getTargetException() != null) {
+ cause = ((InvocationTargetException) cause).getTargetException();
+ }
+
+ String message = cause.getMessage();
+ if (message == null || message.trim().isEmpty()) {
+ return cause.getClass().getName();
+ } else {
+ return cause.getClass().getName() + ": " + message;
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/AppFooterHelper.java b/app/src/main/java/com/dhangofa/networktoggle/ui/AppFooterHelper.java
new file mode 100644
index 00000000..24392c46
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/AppFooterHelper.java
@@ -0,0 +1,46 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.app.Activity;
+import android.content.pm.PackageInfo;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import com.dhangofa.networktoggle.R;
+
+public class AppFooterHelper {
+ public static void setupFooter(Activity activity) {
+ TextView appVersionText = activity.findViewById(R.id.appVersionText);
+ if (appVersionText != null) {
+ appVersionText.setText(activity.getString(R.string.app_version_format, getAppVersionName(activity)));
+ }
+
+ ImageView githubLink = activity.findViewById(R.id.githubLink);
+ if (githubLink != null) {
+ githubLink.setOnClickListener(v -> GuidesTabHelper.openUrl(activity, "https://github.com/Dhangofa/NetToggle"));
+ }
+
+ ImageView telegramLink = activity.findViewById(R.id.telegramLink);
+ if (telegramLink != null) {
+ telegramLink.setOnClickListener(v -> GuidesTabHelper.openUrl(activity, "https://t.me/dhangofas_projects_chat"));
+ }
+
+ View developerName = activity.findViewById(R.id.developerNameText);
+ if (developerName != null) {
+ developerName.setOnClickListener(v -> GuidesTabHelper.openUrl(activity, "https://github.com/Dhangofa"));
+ }
+
+ View appLicenseText = activity.findViewById(R.id.appLicenseText);
+ if (appLicenseText != null) {
+ appLicenseText.setOnClickListener(v -> GuidesTabHelper.openUrl(activity, "https://www.gnu.org/licenses/gpl-3.0.html"));
+ }
+ }
+
+ private static String getAppVersionName(Activity activity) {
+ try {
+ PackageInfo info = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0);
+ return info.versionName;
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/BroadcastTabHelper.java b/app/src/main/java/com/dhangofa/networktoggle/ui/BroadcastTabHelper.java
new file mode 100644
index 00000000..ab86f341
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/BroadcastTabHelper.java
@@ -0,0 +1,510 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Typeface;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.text.Editable;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.Switch;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.dhangofa.networktoggle.MainActivity;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+public class BroadcastTabHelper {
+
+ private String selectedMode = "5G";
+ private int selectedSim = 1;
+
+ private final Activity activity;
+ private final AppPreferences prefs;
+
+ private Switch switchExt;
+ private TextView badgeStatus;
+ private View btnTestBroadcast;
+ private boolean isAuthorized = false;
+ private boolean updatingUi = false;
+ private Runnable refreshCapabilitiesRunnable;
+
+ public BroadcastTabHelper(Activity activity, AppPreferences prefs) {
+ this.activity = activity;
+ this.prefs = prefs;
+ }
+
+ public static BroadcastTabHelper setupTab(Activity activity, AppPreferences prefs) {
+ BroadcastTabHelper helper = new BroadcastTabHelper(activity, prefs);
+ helper.setup();
+ return helper;
+ }
+
+ public void setAuthorized(boolean authorized) {
+ this.isAuthorized = authorized;
+ if (!authorized && switchExt != null && switchExt.isChecked()) {
+ updatingUi = true;
+ switchExt.setChecked(false);
+ prefs.setExternalAutomationEnabled(false);
+ updatingUi = false;
+ }
+ updateStatusBadge();
+ updateTestButtonState();
+ refreshCapabilities();
+ }
+
+ public void refreshCapabilities() {
+ if (refreshCapabilitiesRunnable != null) {
+ activity.runOnUiThread(refreshCapabilitiesRunnable);
+ }
+ }
+
+ private List getAvailableSimSlots() {
+ List slots = new ArrayList<>();
+ if (activity.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
+ SubscriptionManager sm = (SubscriptionManager) activity.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE);
+ if (sm != null) {
+ try {
+ List list = sm.getActiveSubscriptionInfoList();
+ if (list != null) {
+ for (SubscriptionInfo info : list) {
+ int slotIndex = info.getSimSlotIndex();
+ if (slotIndex >= 0) {
+ int simNum = slotIndex + 1;
+ if (!slots.contains(simNum)) {
+ slots.add(simNum);
+ }
+ }
+ }
+ }
+ } catch (SecurityException ignored) {
+ }
+ }
+ }
+ if (slots.isEmpty()) {
+ // When phone-state permission is missing or subscription query returns empty,
+ // fall back to offering SIM 1 and SIM 2 (and multi-SIM "Both") so dual-SIM users
+ // can generate external automation broadcasts.
+ slots.add(1);
+ slots.add(2);
+ }
+ return slots;
+ }
+
+ private void updateStatusBadge() {
+ if (badgeStatus != null && switchExt != null) {
+ boolean enabled = switchExt.isChecked();
+ if (enabled) {
+ badgeStatus.setText(R.string.status_ready);
+ badgeStatus.setTextColor(activity.getColor(R.color.status_success_text));
+ badgeStatus.setBackgroundResource(R.drawable.shape_status_badge_ready);
+ } else {
+ badgeStatus.setText(R.string.status_disabled);
+ badgeStatus.setTextColor(activity.getColor(R.color.status_disabled_text));
+ badgeStatus.setBackgroundResource(R.drawable.shape_status_badge_disabled);
+ }
+ }
+ }
+
+ private void updateTestButtonState() {
+ if (btnTestBroadcast != null) {
+ boolean active = isAuthorized && prefs.isExternalAutomationEnabled();
+ btnTestBroadcast.setAlpha(active ? 1.0f : 0.4f);
+ }
+ }
+
+ public void setup() {
+ if (activity instanceof MainActivity) {
+ this.isAuthorized = ((MainActivity) activity).isExecutionAuthorized();
+ }
+
+ switchExt = activity.findViewById(R.id.switchExternalAutomation);
+ badgeStatus = activity.findViewById(R.id.badgeAutomationStatus);
+ btnTestBroadcast = activity.findViewById(R.id.btnTestBroadcast);
+ View btnCopyIntent = activity.findViewById(R.id.btnCopyIntent);
+ View blockShellCommand = activity.findViewById(R.id.blockShellCommand);
+ TextView textShellCommand = activity.findViewById(R.id.textShellCommand);
+ TextView editToken = activity.findViewById(R.id.editAutomationToken);
+ View btnCopyToken = activity.findViewById(R.id.btnCopyToken);
+ View containerToken = activity.findViewById(R.id.containerAutomationToken);
+ TextView btnGen = activity.findViewById(R.id.btnGenerateToken);
+
+ // Map mode chips
+ Map modeChips = new HashMap<>();
+ modeChips.put("5G", activity.findViewById(R.id.chipMode5G));
+ modeChips.put("4G", activity.findViewById(R.id.chipMode4G));
+ modeChips.put("PREF_5G", activity.findViewById(R.id.chipModePref5G));
+ modeChips.put("PREF_4G", activity.findViewById(R.id.chipModePref4G));
+ modeChips.put("PREF_3G", activity.findViewById(R.id.chipModePref3G));
+ modeChips.put("2G", activity.findViewById(R.id.chipMode2G));
+
+ // Map sim chips
+ Map simChips = new HashMap<>();
+ simChips.put(1, activity.findViewById(R.id.chipSim1));
+ simChips.put(2, activity.findViewById(R.id.chipSim2));
+ simChips.put(3, activity.findViewById(R.id.chipSimBoth));
+
+ if (switchExt != null) {
+ updatingUi = true;
+ boolean isChecked = isAuthorized && prefs.isExternalAutomationEnabled();
+ if (!isAuthorized && prefs.isExternalAutomationEnabled()) {
+ prefs.setExternalAutomationEnabled(false);
+ }
+ switchExt.setChecked(isChecked);
+ updatingUi = false;
+ updateStatusBadge();
+ updateTestButtonState();
+
+ switchExt.setOnTouchListener((v, event) -> {
+ if (!isAuthorized) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ Toast.makeText(activity, R.string.toast_auth_required_automation, Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }
+ return false;
+ });
+
+ switchExt.setOnClickListener(v -> {
+ if (!isAuthorized) {
+ updatingUi = true;
+ switchExt.setChecked(false);
+ updatingUi = false;
+ Toast.makeText(activity, R.string.toast_auth_required_automation, Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ switchExt.setOnCheckedChangeListener((buttonView, isCheckedVal) -> {
+ if (updatingUi) return;
+ if (isCheckedVal && !isAuthorized) {
+ updatingUi = true;
+ buttonView.post(() -> switchExt.setChecked(false));
+ updatingUi = false;
+ Toast.makeText(activity, R.string.toast_auth_required_automation, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (isCheckedVal) {
+ String currentToken = prefs.getAutomationToken();
+ if (currentToken == null || currentToken.trim().isEmpty()) {
+ currentToken = UUID.randomUUID().toString().replace("-", "");
+ prefs.setAutomationToken(currentToken);
+ }
+ if (editToken != null) {
+ editToken.setText(currentToken);
+ }
+ }
+ prefs.setExternalAutomationEnabled(isCheckedVal);
+ updateStatusBadge();
+ updateTestButtonState();
+ });
+ }
+
+ View rowToggle = activity.findViewById(R.id.rowExternalAutomationToggle);
+ if (rowToggle != null && switchExt != null) {
+ rowToggle.setOnClickListener(v -> {
+ if (!isAuthorized) {
+ Toast.makeText(activity, R.string.toast_auth_required_automation, Toast.LENGTH_SHORT).show();
+ } else {
+ switchExt.setChecked(!switchExt.isChecked());
+ }
+ });
+ }
+
+ // Token display & actions (non-editable, copy & regenerate)
+ if (editToken != null) {
+ editToken.setText(prefs.getAutomationToken());
+ }
+
+ View.OnClickListener copyTokenAction = v -> {
+ String token = prefs.getAutomationToken();
+ ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null && token != null && !token.isEmpty()) {
+ ClipData clip = ClipData.newPlainText(activity.getString(R.string.clipboard_label_automation_token), token);
+ clipboard.setPrimaryClip(clip);
+ Toast.makeText(activity, R.string.token_copied_to_clipboard, Toast.LENGTH_SHORT).show();
+ }
+ };
+
+ if (btnCopyToken != null) {
+ btnCopyToken.setOnClickListener(copyTokenAction);
+ }
+ if (editToken != null) {
+ editToken.setOnClickListener(copyTokenAction);
+ }
+ if (containerToken != null) {
+ containerToken.setOnClickListener(copyTokenAction);
+ }
+
+ if (btnGen != null) {
+ btnGen.setOnClickListener(v -> {
+ String randomToken = UUID.randomUUID().toString().replace("-", "");
+ prefs.setAutomationToken(randomToken);
+ if (editToken != null) {
+ editToken.setText(randomToken);
+ }
+ updateShellPreview(textShellCommand);
+ Toast.makeText(activity, R.string.token_generated, Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ // SIM and Mode Capability-Aware Logic
+ Runnable updateSimChips = () -> {
+ for (Map.Entry entry : simChips.entrySet()) {
+ TextView chip = entry.getValue();
+ if (chip == null) continue;
+ boolean isSelected = entry.getKey() == selectedSim;
+ if (isSelected) {
+ chip.setBackgroundResource(R.drawable.shape_chip_selected_green);
+ chip.setTextColor(activity.getColor(R.color.status_success_text));
+ chip.setTypeface(null, Typeface.BOLD);
+ } else {
+ chip.setBackgroundResource(R.drawable.shape_chip_unselected);
+ chip.setTextColor(activity.getColor(R.color.text_primary));
+ chip.setTypeface(null, Typeface.NORMAL);
+ }
+ }
+ };
+
+ Runnable updateModeChips = () -> {
+ for (Map.Entry entry : modeChips.entrySet()) {
+ TextView chip = entry.getValue();
+ if (chip == null) continue;
+ boolean isSelected = entry.getKey().equals(selectedMode);
+ if (isSelected) {
+ chip.setBackgroundResource(R.drawable.shape_chip_selected_orange);
+ chip.setTextColor(activity.getColor(R.color.cycle_accent));
+ chip.setTypeface(null, Typeface.BOLD);
+ } else {
+ chip.setBackgroundResource(R.drawable.shape_chip_unselected);
+ chip.setTextColor(activity.getColor(R.color.text_primary));
+ chip.setTypeface(null, Typeface.NORMAL);
+ }
+ }
+ };
+
+ this.refreshCapabilitiesRunnable = () -> {
+ List availableSims = getAvailableSimSlots();
+ boolean multiSim = availableSims.size() > 1;
+
+ TextView chipSim1 = simChips.get(1);
+ TextView chipSim2 = simChips.get(2);
+ TextView chipSimBoth = simChips.get(3);
+
+ if (chipSim1 != null) {
+ chipSim1.setVisibility(availableSims.contains(1) ? View.VISIBLE : View.GONE);
+ }
+ if (chipSim2 != null) {
+ chipSim2.setVisibility(availableSims.contains(2) ? View.VISIBLE : View.GONE);
+ }
+ if (chipSimBoth != null) {
+ chipSimBoth.setVisibility(multiSim ? View.VISIBLE : View.GONE);
+ }
+
+ // Ensure selected SIM is valid
+ boolean simValid = false;
+ if (selectedSim == 1 && availableSims.contains(1)) simValid = true;
+ else if (selectedSim == 2 && availableSims.contains(2)) simValid = true;
+ else if (selectedSim == 3 && multiSim) simValid = true;
+
+ if (!simValid) {
+ selectedSim = availableSims.get(0);
+ }
+ updateSimChips.run();
+
+ // Determine capabilities for selected SIM
+ AppPreferences.NetworkCapabilities deviceCaps = prefs.getDeviceCapabilities();
+ if (deviceCaps == null) deviceCaps = AppPreferences.NetworkCapabilities.assumeAll();
+ AppPreferences.NetworkCapabilities sim1Caps = prefs.getSlotCapabilities(0);
+ if (sim1Caps == null) sim1Caps = deviceCaps;
+ AppPreferences.NetworkCapabilities sim2Caps = prefs.getSlotCapabilities(1);
+ if (sim2Caps == null) sim2Caps = deviceCaps;
+
+ AppPreferences.NetworkCapabilities caps;
+ if (selectedSim == 1) {
+ caps = sim1Caps;
+ } else if (selectedSim == 2) {
+ caps = sim2Caps;
+ } else {
+ caps = new AppPreferences.NetworkCapabilities(
+ sim1Caps.supports2g && sim2Caps.supports2g,
+ sim1Caps.supports3g && sim2Caps.supports3g,
+ sim1Caps.supports4g && sim2Caps.supports4g,
+ sim1Caps.supports5g && sim2Caps.supports5g
+ );
+ }
+
+ // Update mode chip visibilities
+ TextView chip5G = modeChips.get("5G");
+ TextView chip4G = modeChips.get("4G");
+ TextView chipPref5G = modeChips.get("PREF_5G");
+ TextView chipPref4G = modeChips.get("PREF_4G");
+ TextView chipPref3G = modeChips.get("PREF_3G");
+ TextView chip2G = modeChips.get("2G");
+
+ if (chip5G != null) chip5G.setVisibility(caps.supports5g ? View.VISIBLE : View.GONE);
+ if (chip4G != null) chip4G.setVisibility(caps.supports4g ? View.VISIBLE : View.GONE);
+ if (chipPref5G != null) chipPref5G.setVisibility(caps.supports5g ? View.VISIBLE : View.GONE);
+ if (chipPref4G != null) chipPref4G.setVisibility(caps.supports4g ? View.VISIBLE : View.GONE);
+ if (chipPref3G != null) chipPref3G.setVisibility(caps.supports3g ? View.VISIBLE : View.GONE);
+ if (chip2G != null) chip2G.setVisibility(caps.supports2g ? View.VISIBLE : View.GONE);
+
+ // Ensure selected mode is valid for the capabilities
+ boolean modeValid = false;
+ if ("5G".equals(selectedMode) && caps.supports5g) modeValid = true;
+ else if ("4G".equals(selectedMode) && caps.supports4g) modeValid = true;
+ else if ("PREF_5G".equals(selectedMode) && caps.supports5g) modeValid = true;
+ else if ("PREF_4G".equals(selectedMode) && caps.supports4g) modeValid = true;
+ else if ("PREF_3G".equals(selectedMode) && caps.supports3g) modeValid = true;
+ else if ("2G".equals(selectedMode) && caps.supports2g) modeValid = true;
+
+ if (!modeValid) {
+ if (caps.supports5g) selectedMode = "5G";
+ else if (caps.supports4g) selectedMode = "4G";
+ else if (caps.supports3g) selectedMode = "PREF_3G";
+ else if (caps.supports2g) selectedMode = "2G";
+ else selectedMode = "4G";
+ }
+ updateModeChips.run();
+ updateShellPreview(textShellCommand);
+ };
+
+ for (Map.Entry entry : modeChips.entrySet()) {
+ TextView chip = entry.getValue();
+ if (chip != null) {
+ chip.setOnClickListener(v -> {
+ selectedMode = entry.getKey();
+ updateModeChips.run();
+ updateShellPreview(textShellCommand);
+ });
+ }
+ }
+
+ for (Map.Entry entry : simChips.entrySet()) {
+ TextView chip = entry.getValue();
+ if (chip != null) {
+ chip.setOnClickListener(v -> {
+ selectedSim = entry.getKey();
+ refreshCapabilitiesRunnable.run();
+ });
+ }
+ }
+
+ refreshCapabilitiesRunnable.run();
+
+ // Shell command initial update
+ updateShellPreview(textShellCommand);
+
+ // Copy shell command on tap
+ if (blockShellCommand != null) {
+ blockShellCommand.setOnClickListener(v -> {
+ String cmd = getShellCommand();
+ copyToClipboard(activity.getString(R.string.clipboard_label_adb_cmd), cmd);
+ Toast.makeText(activity, R.string.toast_adb_cmd_copied, Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ // Copy Specs button
+ if (btnCopyIntent != null) {
+ btnCopyIntent.setOnClickListener(v -> {
+ String token = prefs.getAutomationToken();
+ if (token == null) token = "";
+ String specs = activity.getString(R.string.spec_action_type) + ": Send Intent\n"
+ + activity.getString(R.string.spec_target) + ": Broadcast\n"
+ + activity.getString(R.string.action_label) + " com.dhangofa.networktoggle.SET_MODE\n"
+ + activity.getString(R.string.spec_package) + ": com.dhangofa.networktoggle\n"
+ + activity.getString(R.string.spec_class) + ": com.dhangofa.networktoggle.AutomationReceiver\n"
+ + activity.getString(R.string.spec_extra) + " 1: mode: " + selectedMode + "\n"
+ + activity.getString(R.string.spec_extra) + " 2: sim: " + selectedSim + "\n"
+ + activity.getString(R.string.spec_extra) + " 3: token: " + token;
+
+ copyToClipboard(activity.getString(R.string.clipboard_label_broadcast_specs), specs);
+ Toast.makeText(activity, R.string.toast_broadcast_specs_copied, Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ // Test Broadcast button
+ if (btnTestBroadcast != null) {
+ updateTestButtonState();
+ btnTestBroadcast.setOnClickListener(v -> {
+ if (!prefs.isExternalAutomationEnabled()) {
+ Toast.makeText(activity, R.string.toast_ext_automation_disabled, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!isAuthorized) {
+ Toast.makeText(activity, R.string.toast_auth_required_automation, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ Intent intent = new Intent("com.dhangofa.networktoggle.SET_MODE");
+ intent.setPackage(activity.getPackageName());
+ intent.putExtra("mode", selectedMode);
+ intent.putExtra("sim", selectedSim);
+ String token = prefs.getAutomationToken();
+ if (token != null && !token.isEmpty()) {
+ intent.putExtra("token", token);
+ }
+ activity.sendBroadcast(intent);
+
+ String simLabel = (selectedSim == 3)
+ ? activity.getString(R.string.sim_both)
+ : String.format(activity.getString(R.string.sim_label_format), selectedSim);
+ String message = String.format(activity.getString(R.string.toast_broadcast_sent), selectedMode, simLabel);
+ Toast.makeText(activity, message, Toast.LENGTH_SHORT).show();
+ });
+ }
+ }
+
+ private String getShellCommand() {
+ String token = prefs.getAutomationToken();
+ if (token == null) token = "";
+ return "am broadcast -a com.dhangofa.networktoggle.SET_MODE -n com.dhangofa.networktoggle/.AutomationReceiver --es mode " + selectedMode + " --ei sim " + selectedSim + " --es token " + token;
+ }
+
+ private Spanned getShellCommandHtml() {
+ String token = prefs.getAutomationToken();
+ if (token == null) token = "";
+ String html = "am broadcast "
+ + "-a "
+ + "com.dhangofa.networktoggle.SET_MODE "
+ + "-n "
+ + "com.dhangofa.networktoggle/.AutomationReceiver "
+ + "--es mode "
+ + "" + selectedMode + " "
+ + "--ei sim "
+ + "" + selectedSim + " "
+ + "--es token "
+ + "" + token + " ";
+ return Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
+ }
+
+ private void updateShellPreview(TextView textShellCommand) {
+ if (textShellCommand != null) {
+ textShellCommand.setText(getShellCommandHtml());
+ }
+ }
+
+ private void copyToClipboard(String label, String text) {
+ ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ ClipData clip = ClipData.newPlainText(label, text);
+ clipboard.setPrimaryClip(clip);
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/DiagnosticUiController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/DiagnosticUiController.java
new file mode 100644
index 00000000..7b89ff3e
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/DiagnosticUiController.java
@@ -0,0 +1,60 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.view.View;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.DiagnosticError;
+import com.dhangofa.networktoggle.telephony.SimResolver;
+
+public class DiagnosticUiController {
+ private final Activity activity;
+ private final AppPreferences appPreferences;
+
+ private View errorBannerContainer;
+ private Dialog diagnosticDialog;
+ private boolean isDestroyed = false;
+
+ public DiagnosticUiController(Activity activity, AppPreferences appPreferences) {
+ this.activity = activity;
+ this.appPreferences = appPreferences;
+ }
+
+ public void bindViews() {
+ this.errorBannerContainer = activity.findViewById(R.id.cardErrorBanner);
+ if (this.errorBannerContainer != null) {
+ this.errorBannerContainer.setOnClickListener(v -> showDiagnosticDialog());
+ }
+ updateErrorBanner();
+ }
+
+ public void updateErrorBanner() {
+ if (this.errorBannerContainer != null && this.appPreferences != null) {
+ DiagnosticError error = this.appPreferences.getLastError();
+ this.errorBannerContainer.setVisibility(error != null ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ private void showDiagnosticDialog() {
+ if (activity.isFinishing() || isDestroyed) {
+ return;
+ }
+ if (this.diagnosticDialog != null && this.diagnosticDialog.isShowing()) {
+ return;
+ }
+ this.diagnosticDialog = DialogHelper.buildDiagnosticDialog(activity, this.appPreferences, new SimResolver(activity, this.appPreferences), () -> {
+ this.appPreferences.clearLastError();
+ this.updateErrorBanner();
+ });
+ this.diagnosticDialog.show();
+ }
+
+ public void destroy() {
+ this.isDestroyed = true;
+ if (this.diagnosticDialog != null && this.diagnosticDialog.isShowing()) {
+ this.diagnosticDialog.dismiss();
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/DialogHelper.java b/app/src/main/java/com/dhangofa/networktoggle/ui/DialogHelper.java
new file mode 100644
index 00000000..fbe9a883
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/DialogHelper.java
@@ -0,0 +1,131 @@
+package com.dhangofa.networktoggle.ui;
+
+/**
+ * Helper to build and show the various popup dialogs in the app
+ * (like the permission bottom sheet or the diagnostic error dialog).
+ */
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.telephony.SimResolver;
+import com.dhangofa.networktoggle.util.DiagnosticReporter;
+
+public final class DialogHelper {
+
+ private DialogHelper() {}
+
+ public static Dialog buildPermissionBottomSheet(Activity activity, Runnable onGrantClicked) {
+ return buildPermissionBottomSheet(activity, false, onGrantClicked, null);
+ }
+
+ public static Dialog buildPermissionBottomSheet(
+ Activity activity,
+ boolean isPermanentlyDenied,
+ Runnable onActionClicked,
+ Runnable onDismissClicked
+ ) {
+ Dialog dialog = new Dialog(activity, R.style.TransparentBottomSheetStyle);
+ View view = activity.getLayoutInflater().inflate(R.layout.bottom_sheet_permission, null);
+
+ TextView txtDesc = view.findViewById(R.id.txtPermissionDesc);
+ Button btnGrant = view.findViewById(R.id.btnGrantPermission);
+ Button btnDismiss = view.findViewById(R.id.btnDismissPermission);
+
+ if (isPermanentlyDenied) {
+ if (txtDesc != null) {
+ txtDesc.setText(R.string.permission_permanently_denied_desc);
+ }
+ if (btnGrant != null) {
+ btnGrant.setText(R.string.open_settings);
+ }
+ } else {
+ if (txtDesc != null) {
+ txtDesc.setText(R.string.nettoggle_operates_faster_if_it_can_dire);
+ }
+ if (btnGrant != null) {
+ btnGrant.setText(R.string.grant_permission);
+ }
+ }
+
+ if (btnDismiss != null) {
+ btnDismiss.setOnClickListener(v -> {
+ dialog.dismiss();
+ if (onDismissClicked != null) {
+ onDismissClicked.run();
+ }
+ });
+ }
+
+ if (btnGrant != null) {
+ btnGrant.setOnClickListener(v -> {
+ dialog.dismiss();
+ if (onActionClicked != null) {
+ onActionClicked.run();
+ }
+ });
+ }
+
+ dialog.setOnCancelListener(d -> {
+ if (onDismissClicked != null) {
+ onDismissClicked.run();
+ }
+ });
+
+ dialog.setContentView(view);
+ Window window = dialog.getWindow();
+ if (window != null) {
+ window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ window.setGravity(Gravity.BOTTOM);
+ window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ }
+ return dialog;
+ }
+
+ public static Dialog buildDiagnosticDialog(Activity activity, AppPreferences appPreferences, SimResolver simResolver, Runnable onClose) {
+ Dialog dialog = new Dialog(activity);
+ dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
+ dialog.setContentView(R.layout.dialog_diagnostic);
+ dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ TextView reportText = dialog.findViewById(R.id.diagnosticReportText);
+ if (reportText != null) {
+ String report = DiagnosticReporter.generateReport(activity, appPreferences, simResolver);
+ reportText.setText(report);
+ }
+
+ View btnClose = dialog.findViewById(R.id.btnCloseDiagnostic);
+ if (btnClose != null) {
+ btnClose.setOnClickListener(v -> {
+ dialog.dismiss();
+ onClose.run();
+ });
+ }
+
+ View btnCopy = dialog.findViewById(R.id.btnCopyDiagnostic);
+ if (btnCopy != null) {
+ btnCopy.setOnClickListener(v -> {
+ ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Activity.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("Diagnostic Report", reportText.getText());
+ clipboard.setPrimaryClip(clip);
+ Toast.makeText(activity, activity.getString(R.string.toast_report_copied), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ return dialog;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionModeUiController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionModeUiController.java
new file mode 100644
index 00000000..225ef57e
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionModeUiController.java
@@ -0,0 +1,532 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.app.Activity;
+import android.transition.ChangeBounds;
+import android.transition.Fade;
+import android.transition.TransitionManager;
+import android.transition.TransitionSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.OvershootInterpolator;
+import android.widget.ImageView;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import com.dhangofa.networktoggle.MainActivity;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import rikka.shizuku.Shizuku;
+
+public class ExecutionModeUiController {
+ private final Activity activity;
+ private final AppPreferences appPreferences;
+ private final ExecutionStateController stateController;
+
+ public interface AuthStateCallback {
+ void onAuthStateChanged(boolean authorized);
+ }
+ private final AuthStateCallback authStateCallback;
+
+ public interface ModeChangeListener {
+ void onModeChanged(ExecutionMode mode);
+ }
+ private ModeChangeListener modeChangeListener;
+
+ // Main Container Cards
+ private View cardSetupHero;
+ private View cardExecutionAndTarget;
+
+ // Setup Hero elements
+ private View cardSelectShizuku;
+ private View cardSelectRoot;
+ private ImageView radioIndicatorShizuku;
+ private ImageView radioIndicatorRoot;
+ private View shizukuStatusDot;
+ private TextView shizukuLiveStatusText;
+ private View rootStatusDot;
+ private TextView rootLiveStatusText;
+ private View btnSetupAuthorize;
+ private TextView setupActionBtnText;
+ private View btnOpenGuidesLink;
+
+ // Authorized Compact Card elements
+ private RadioGroup radioGroup;
+ private RadioButton radioRoot;
+ private RadioButton radioShizuku;
+ private TextView statusText;
+ private View separatorRootShizuku;
+
+ private ExecutionMode selectedSetupMode = ExecutionMode.SHIZUKU;
+ private Boolean isUIAuthorized = null;
+ private boolean isProgrammaticCheck = false;
+
+ public ExecutionModeUiController(Activity activity, AppPreferences appPreferences, ExecutionStateController stateController, AuthStateCallback authStateCallback) {
+ this.activity = activity;
+ this.appPreferences = appPreferences;
+ this.stateController = stateController;
+ this.authStateCallback = authStateCallback;
+ }
+
+ public void setOnModeChangeListener(ModeChangeListener listener) {
+ this.modeChangeListener = listener;
+ }
+
+ public void bindViews() {
+ // Main Cards (Support both include ID and layout root ID)
+ this.cardSetupHero = activity.findViewById(R.id.cardSetupHero);
+ if (this.cardSetupHero == null) {
+ this.cardSetupHero = activity.findViewById(R.id.cardSetupHeroRoot);
+ }
+ this.cardExecutionAndTarget = activity.findViewById(R.id.cardExecutionAndTarget);
+
+ // Setup Hero Views
+ this.cardSelectShizuku = activity.findViewById(R.id.cardSelectShizuku);
+ this.cardSelectRoot = activity.findViewById(R.id.cardSelectRoot);
+ this.radioIndicatorShizuku = activity.findViewById(R.id.radioIndicatorShizuku);
+ this.radioIndicatorRoot = activity.findViewById(R.id.radioIndicatorRoot);
+ this.shizukuStatusDot = activity.findViewById(R.id.shizukuStatusDot);
+ this.shizukuLiveStatusText = activity.findViewById(R.id.shizukuLiveStatusText);
+ this.rootStatusDot = activity.findViewById(R.id.rootStatusDot);
+ this.rootLiveStatusText = activity.findViewById(R.id.rootLiveStatusText);
+ this.btnSetupAuthorize = activity.findViewById(R.id.btnSetupAuthorize);
+ this.setupActionBtnText = activity.findViewById(R.id.setupActionBtnText);
+ this.btnOpenGuidesLink = activity.findViewById(R.id.btnOpenGuidesLink);
+
+ // Authorized Views
+ this.radioGroup = activity.findViewById(R.id.modeRadioGroup);
+ this.radioRoot = activity.findViewById(R.id.radioRoot);
+ this.radioShizuku = activity.findViewById(R.id.radioShizuku);
+ this.statusText = activity.findViewById(R.id.shizukuStatusText);
+ this.separatorRootShizuku = activity.findViewById(R.id.separatorRootShizuku);
+
+ bindSetupHeroListeners();
+ bindAuthorizedListeners();
+ loadSavedExecutionMode();
+ }
+
+ private void bindSetupHeroListeners() {
+ if (cardSelectShizuku != null) {
+ cardSelectShizuku.setOnClickListener(v -> selectSetupMode(ExecutionMode.SHIZUKU));
+ }
+ if (cardSelectRoot != null) {
+ cardSelectRoot.setOnClickListener(v -> selectSetupMode(ExecutionMode.ROOT));
+ }
+ if (btnSetupAuthorize != null) {
+ btnSetupAuthorize.setOnClickListener(v -> {
+ if (selectedSetupMode == ExecutionMode.SHIZUKU) {
+ appPreferences.onExecutionModeChanged(ExecutionMode.SHIZUKU);
+ stateController.checkShizukuPermission(true);
+ } else if (selectedSetupMode == ExecutionMode.ROOT) {
+ updateRootHeroLiveStatus(true, false, false);
+ appPreferences.onExecutionModeChanged(ExecutionMode.ROOT);
+ stateController.checkRootPermission();
+ }
+ });
+ }
+ if (btnOpenGuidesLink != null) {
+ btnOpenGuidesLink.setOnClickListener(v -> {
+ if (activity instanceof MainActivity) {
+ ((MainActivity) activity).getNavigationController().switchTab(2);
+ }
+ });
+ }
+ }
+
+ private void selectSetupMode(ExecutionMode mode) {
+ if (this.selectedSetupMode == mode) return;
+ this.selectedSetupMode = mode;
+ View selectedView = (mode == ExecutionMode.SHIZUKU) ? cardSelectShizuku : cardSelectRoot;
+ if (selectedView != null) {
+ selectedView.setScaleX(0.96f);
+ selectedView.setScaleY(0.96f);
+ selectedView.animate()
+ .scaleX(1.0f)
+ .scaleY(1.0f)
+ .setDuration(160)
+ .setInterpolator(new OvershootInterpolator(1.2f))
+ .start();
+ }
+ if (mode == ExecutionMode.SHIZUKU) {
+ if (cardSelectShizuku != null) {
+ cardSelectShizuku.setBackgroundResource(R.drawable.shape_setup_option_selected);
+ }
+ if (radioIndicatorShizuku != null) {
+ radioIndicatorShizuku.setImageResource(R.drawable.shape_radio_selected);
+ }
+ if (cardSelectRoot != null) {
+ cardSelectRoot.setBackgroundResource(R.drawable.shape_setup_option_unselected);
+ }
+ if (radioIndicatorRoot != null) {
+ radioIndicatorRoot.setImageResource(R.drawable.shape_radio_unselected);
+ }
+ updateActionButtonTextWithCrossfade(R.string.btn_authorize_shizuku);
+ } else {
+ if (cardSelectRoot != null) {
+ cardSelectRoot.setBackgroundResource(R.drawable.shape_setup_option_selected);
+ }
+ if (radioIndicatorRoot != null) {
+ radioIndicatorRoot.setImageResource(R.drawable.shape_radio_selected);
+ }
+ if (cardSelectShizuku != null) {
+ cardSelectShizuku.setBackgroundResource(R.drawable.shape_setup_option_unselected);
+ }
+ if (radioIndicatorShizuku != null) {
+ radioIndicatorShizuku.setImageResource(R.drawable.shape_radio_unselected);
+ }
+ updateActionButtonTextWithCrossfade(R.string.btn_grant_root);
+ }
+ }
+
+ private void updateActionButtonTextWithCrossfade(int stringResId) {
+ if (setupActionBtnText == null) return;
+ String newText = activity.getString(stringResId);
+ if (setupActionBtnText.getText().toString().equals(newText)) return;
+ setupActionBtnText.animate()
+ .alpha(0f)
+ .setDuration(80)
+ .withEndAction(() -> {
+ setupActionBtnText.setText(newText);
+ setupActionBtnText.animate()
+ .alpha(1f)
+ .setDuration(120)
+ .start();
+ })
+ .start();
+ }
+
+ private void bindAuthorizedListeners() {
+ if (this.radioGroup != null) {
+ this.radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (isProgrammaticCheck) {
+ this.updateSeparatorVisibility();
+ return;
+ }
+ this.updateSeparatorVisibility();
+
+ if (checkedId != R.id.radioRoot && checkedId != R.id.radioShizuku) {
+ return;
+ }
+
+ ExecutionMode newMode = (checkedId == R.id.radioRoot) ? ExecutionMode.ROOT : ExecutionMode.SHIZUKU;
+ ExecutionMode oldMode = this.appPreferences.getExecutionMode();
+
+ if (isExecutionAuthorized() && oldMode != newMode) {
+ animateSegmentSelection(checkedId);
+ animateCardFeedback();
+ }
+
+ this.appPreferences.onExecutionModeChanged(newMode);
+
+ if (this.modeChangeListener != null) {
+ this.modeChangeListener.onModeChanged(newMode);
+ }
+
+ if (checkedId == R.id.radioRoot) {
+ this.stateController.checkRootPermission();
+ } else if (checkedId == R.id.radioShizuku) {
+ this.stateController.checkShizukuPermission(true);
+ }
+ });
+ }
+ }
+
+ private void updateSeparatorVisibility() {
+ if (this.radioGroup == null || this.separatorRootShizuku == null) return;
+ int modeId = this.radioGroup.getCheckedRadioButtonId();
+ if (modeId == -1) {
+ this.separatorRootShizuku.setVisibility(View.VISIBLE);
+ } else {
+ this.separatorRootShizuku.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ private void loadSavedExecutionMode() {
+ isProgrammaticCheck = true;
+ try {
+ ExecutionMode savedMode = this.appPreferences.getExecutionMode();
+ if (savedMode == ExecutionMode.ROOT) {
+ selectSetupMode(ExecutionMode.ROOT);
+ if (this.radioRoot != null) this.radioRoot.setChecked(true);
+ this.stateController.checkRootPermission();
+ } else if (savedMode == ExecutionMode.SHIZUKU) {
+ selectSetupMode(ExecutionMode.SHIZUKU);
+ if (this.radioShizuku != null) this.radioShizuku.setChecked(true);
+ this.stateController.checkShizukuPermission(false);
+ } else {
+ selectSetupMode(ExecutionMode.SHIZUKU);
+ if (this.radioGroup != null) this.radioGroup.clearCheck();
+ this.setStatus(activity.getString(R.string.status_select_mode), 3);
+ }
+ } finally {
+ isProgrammaticCheck = false;
+ }
+ updateSeparatorVisibility();
+ updateShizukuHeroLiveStatus();
+ updateRootHeroLiveStatus(false, false, false);
+ }
+
+ private void updateShizukuHeroLiveStatus() {
+ if (shizukuLiveStatusText == null || shizukuStatusDot == null) return;
+ try {
+ boolean running = Shizuku.pingBinder();
+ if (running) {
+ shizukuStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_success_icon));
+ shizukuLiveStatusText.setText(R.string.shizuku_status_running);
+ shizukuLiveStatusText.setTextColor(activity.getColor(R.color.status_success_text));
+ } else {
+ shizukuStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_warning_icon));
+ shizukuLiveStatusText.setText(R.string.shizuku_status_not_running);
+ shizukuLiveStatusText.setTextColor(activity.getColor(R.color.status_warning_text));
+ }
+ } catch (Exception e) {
+ shizukuStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_warning_icon));
+ shizukuLiveStatusText.setText(R.string.shizuku_status_not_running);
+ shizukuLiveStatusText.setTextColor(activity.getColor(R.color.status_warning_text));
+ }
+ }
+
+ private void updateRootHeroLiveStatus(boolean checking, boolean denied, boolean granted) {
+ if (rootLiveStatusText == null || rootStatusDot == null) return;
+ if (checking) {
+ rootStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_warning_icon));
+ rootLiveStatusText.setText(R.string.root_status_checking);
+ rootLiveStatusText.setTextColor(activity.getColor(R.color.status_warning_text));
+ } else if (denied) {
+ rootStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_error_icon));
+ rootLiveStatusText.setText(R.string.root_status_denied);
+ rootLiveStatusText.setTextColor(activity.getColor(R.color.status_error_text));
+ } else if (granted) {
+ rootStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_success_icon));
+ rootLiveStatusText.setText(R.string.root_status_active);
+ rootLiveStatusText.setTextColor(activity.getColor(R.color.status_success_text));
+ } else {
+ rootStatusDot.setBackgroundTintList(activity.getColorStateList(R.color.status_warning_icon));
+ rootLiveStatusText.setText(R.string.root_status_not_granted);
+ rootLiveStatusText.setTextColor(activity.getColor(R.color.status_warning_text));
+ }
+ }
+
+ private void applyStatusColor(int colorCode) {
+ if (this.statusText == null) return;
+ if (colorCode == 1) {
+ this.statusText.setTextColor(activity.getColor(R.color.status_success_text));
+ this.statusText.setBackgroundResource(R.drawable.shape_status_badge_success);
+ } else if (colorCode == 2) {
+ this.statusText.setTextColor(activity.getColor(R.color.status_error_text));
+ this.statusText.setBackgroundResource(R.drawable.shape_status_badge_error);
+ } else {
+ this.statusText.setTextColor(activity.getColor(R.color.status_warning_text));
+ this.statusText.setBackgroundResource(R.drawable.shape_status_badge_warning);
+ }
+ }
+
+ private void applyStatusTextDirect(String text, int colorCode) {
+ if (this.statusText != null) {
+ this.statusText.setText(text);
+ applyStatusColor(colorCode);
+ }
+ }
+
+ private void animateStatusChange(String text, int colorCode) {
+ if (this.statusText == null) return;
+ this.statusText.animate()
+ .alpha(0f)
+ .scaleX(0.85f)
+ .scaleY(0.85f)
+ .setDuration(100)
+ .withEndAction(() -> {
+ this.statusText.setText(text);
+ applyStatusColor(colorCode);
+ this.statusText.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(180)
+ .setInterpolator(new OvershootInterpolator(1.2f))
+ .start();
+ })
+ .start();
+ }
+
+ private void animateSegmentSelection(int checkedId) {
+ RadioButton selected = activity.findViewById(checkedId);
+ if (selected != null) {
+ selected.setScaleX(0.94f);
+ selected.setScaleY(0.94f);
+ selected.animate()
+ .scaleX(1.05f)
+ .scaleY(1.05f)
+ .setDuration(120)
+ .setInterpolator(new DecelerateInterpolator())
+ .withEndAction(() -> selected.animate()
+ .scaleX(1.0f)
+ .scaleY(1.0f)
+ .setDuration(120)
+ .setInterpolator(new OvershootInterpolator(1.2f))
+ .start())
+ .start();
+ }
+ int otherId = (checkedId == R.id.radioRoot) ? R.id.radioShizuku : R.id.radioRoot;
+ RadioButton unselected = activity.findViewById(otherId);
+ if (unselected != null) {
+ unselected.animate().scaleX(1.0f).scaleY(1.0f).setDuration(120).start();
+ }
+ }
+
+ private void animateCardFeedback() {
+ if (cardExecutionAndTarget != null) {
+ cardExecutionAndTarget.animate()
+ .scaleX(1.015f)
+ .scaleY(1.015f)
+ .setDuration(120)
+ .setInterpolator(new DecelerateInterpolator())
+ .withEndAction(() -> cardExecutionAndTarget.animate()
+ .scaleX(1.0f)
+ .scaleY(1.0f)
+ .setDuration(160)
+ .setInterpolator(new DecelerateInterpolator())
+ .start())
+ .start();
+ }
+ }
+
+ private void animateTransitionToAuthorized(boolean authorized) {
+ ViewGroup parent = null;
+ if (cardSetupHero != null && cardSetupHero.getParent() instanceof ViewGroup) {
+ parent = (ViewGroup) cardSetupHero.getParent();
+ } else if (cardExecutionAndTarget != null && cardExecutionAndTarget.getParent() instanceof ViewGroup) {
+ parent = (ViewGroup) cardExecutionAndTarget.getParent();
+ }
+
+ if (parent != null) {
+ TransitionSet transition = new TransitionSet();
+ transition.setOrdering(TransitionSet.ORDERING_TOGETHER);
+ ChangeBounds changeBounds = new ChangeBounds();
+ changeBounds.setDuration(340);
+ changeBounds.setInterpolator(new DecelerateInterpolator());
+ transition.addTransition(changeBounds);
+ Fade fade = new Fade();
+ fade.setDuration(220);
+ transition.addTransition(fade);
+ TransitionManager.beginDelayedTransition(parent, transition);
+ }
+
+ float density = activity.getResources().getDisplayMetrics().density;
+ float offset = 10f * density;
+
+ if (authorized) {
+ if (cardSetupHero != null) {
+ cardSetupHero.animate()
+ .alpha(0f)
+ .scaleY(0.88f)
+ .setDuration(200)
+ .withEndAction(() -> {
+ cardSetupHero.setVisibility(View.GONE);
+ cardSetupHero.setAlpha(1f);
+ cardSetupHero.setScaleY(1f);
+ })
+ .start();
+ }
+ if (cardExecutionAndTarget != null) {
+ cardExecutionAndTarget.setVisibility(View.VISIBLE);
+ cardExecutionAndTarget.setAlpha(0f);
+ cardExecutionAndTarget.setScaleY(0.88f);
+ cardExecutionAndTarget.setTranslationY(-offset);
+ cardExecutionAndTarget.animate()
+ .alpha(1f)
+ .scaleY(1f)
+ .translationY(0f)
+ .setDuration(340)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ } else {
+ if (cardExecutionAndTarget != null) {
+ cardExecutionAndTarget.animate()
+ .alpha(0f)
+ .scaleY(0.88f)
+ .setDuration(200)
+ .withEndAction(() -> {
+ cardExecutionAndTarget.setVisibility(View.GONE);
+ cardExecutionAndTarget.setAlpha(1f);
+ cardExecutionAndTarget.setScaleY(1f);
+ })
+ .start();
+ }
+ if (cardSetupHero != null) {
+ cardSetupHero.setVisibility(View.VISIBLE);
+ cardSetupHero.setAlpha(0f);
+ cardSetupHero.setScaleY(0.88f);
+ cardSetupHero.setTranslationY(-offset);
+ cardSetupHero.animate()
+ .alpha(1f)
+ .scaleY(1f)
+ .translationY(0f)
+ .setDuration(340)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ }
+ }
+
+ public void setStatus(String text, int colorCode) {
+ boolean authorized = (colorCode == 1);
+ boolean stateChanged = (this.isUIAuthorized != null && this.isUIAuthorized != authorized);
+ boolean isFirstRun = (this.isUIAuthorized == null);
+
+ // Handle collapse / expand animations between Setup Hero and Execution Card
+ if (stateChanged) {
+ animateTransitionToAuthorized(authorized);
+ } else if (isFirstRun) {
+ if (cardSetupHero != null) {
+ cardSetupHero.setVisibility(authorized ? View.GONE : View.VISIBLE);
+ }
+ if (cardExecutionAndTarget != null) {
+ cardExecutionAndTarget.setVisibility(authorized ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ if (authorized) {
+ ExecutionMode currentMode = appPreferences.getExecutionMode();
+ if (radioGroup != null) {
+ isProgrammaticCheck = true;
+ try {
+ if (currentMode == ExecutionMode.ROOT && radioRoot != null && !radioRoot.isChecked()) {
+ radioRoot.setChecked(true);
+ } else if (currentMode == ExecutionMode.SHIZUKU && radioShizuku != null && !radioShizuku.isChecked()) {
+ radioShizuku.setChecked(true);
+ }
+ } finally {
+ isProgrammaticCheck = false;
+ }
+ }
+ updateSeparatorVisibility();
+ if (!stateChanged && !isFirstRun) {
+ animateStatusChange(text, colorCode);
+ } else {
+ applyStatusTextDirect(text, colorCode);
+ }
+ } else {
+ applyStatusTextDirect(text, colorCode);
+ updateShizukuHeroLiveStatus();
+ if (colorCode == 2 && selectedSetupMode == ExecutionMode.ROOT) {
+ updateRootHeroLiveStatus(false, true, false);
+ } else {
+ updateRootHeroLiveStatus(false, false, false);
+ }
+ }
+
+ if (this.isUIAuthorized == null || this.isUIAuthorized != authorized) {
+ this.isUIAuthorized = authorized;
+ if (this.authStateCallback != null) {
+ this.authStateCallback.onAuthStateChanged(authorized);
+ }
+ }
+ }
+
+ public boolean isExecutionAuthorized() {
+ return this.isUIAuthorized != null && this.isUIAuthorized;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionStateController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionStateController.java
new file mode 100644
index 00000000..f4076736
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/ExecutionStateController.java
@@ -0,0 +1,170 @@
+package com.dhangofa.networktoggle.ui;
+
+/**
+ * Controller to manage the complex lifecycle of Root shell pings
+ * and Shizuku binder callbacks. It keeps the UI status text in sync with the actual
+ * execution backend state.
+ */
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.service.quicksettings.TileService;
+
+import com.dhangofa.networktoggle.NetworkTileService;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+
+import rikka.shizuku.Shizuku;
+
+public class ExecutionStateController {
+ private final Activity activity;
+ private final AppPreferences appPreferences;
+ private final StatusCallback statusCallback;
+
+ private Thread rootCheckThread;
+ private Process rootCheckProcess;
+ private boolean isDestroyed = false;
+
+ public interface StatusCallback {
+ void onStatusUpdate(String text, int color);
+ }
+
+ private Shizuku.OnBinderReceivedListener binderReceivedListener;
+ private Shizuku.OnBinderDeadListener binderDeadListener;
+ private Shizuku.OnRequestPermissionResultListener permissionResultListener;
+
+ public ExecutionStateController(Activity activity, AppPreferences appPreferences, StatusCallback statusCallback) {
+ this.activity = activity;
+ this.appPreferences = appPreferences;
+ this.statusCallback = statusCallback;
+
+ binderReceivedListener = () ->
+ activity.runOnUiThread(() -> {
+ if (!isDestroyed && appPreferences != null
+ && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ checkShizukuPermission(false);
+ }
+ });
+
+ binderDeadListener = () ->
+ activity.runOnUiThread(() -> {
+ if (!isDestroyed && appPreferences != null
+ && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_not_running), 2);
+ }
+ });
+
+ permissionResultListener =
+ (requestCode, grantResult) -> activity.runOnUiThread(() -> {
+ if (!isDestroyed && appPreferences != null
+ && appPreferences.getExecutionMode() == ExecutionMode.SHIZUKU) {
+ checkShizukuPermission(false);
+ }
+ });
+ }
+
+ public void registerListeners() {
+ Shizuku.addBinderReceivedListener(binderReceivedListener);
+ Shizuku.addBinderDeadListener(binderDeadListener);
+ Shizuku.addRequestPermissionResultListener(permissionResultListener);
+ }
+
+ public void unregisterListeners() {
+ Shizuku.removeBinderReceivedListener(binderReceivedListener);
+ Shizuku.removeBinderDeadListener(binderDeadListener);
+ Shizuku.removeRequestPermissionResultListener(permissionResultListener);
+ }
+
+ public void destroy() {
+ isDestroyed = true;
+ unregisterListeners();
+ if (rootCheckProcess != null) {
+ rootCheckProcess.destroy();
+ rootCheckProcess = null;
+ }
+ if (rootCheckThread != null && rootCheckThread.isAlive()) {
+ rootCheckThread.interrupt();
+ rootCheckThread = null;
+ }
+ }
+
+ public void checkRootPermission() {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_root_checking), 3);
+ if (rootCheckProcess != null) {
+ rootCheckProcess.destroy();
+ rootCheckProcess = null;
+ }
+ if (rootCheckThread != null && rootCheckThread.isAlive()) {
+ rootCheckThread.interrupt();
+ }
+ rootCheckThread = new Thread(() -> {
+ boolean granted = false;
+ Process process = null;
+ try {
+ process = Runtime.getRuntime().exec(new String[]{"su", "-c", "id"});
+ rootCheckProcess = process;
+ granted = process.waitFor() == 0;
+ } catch (Exception ignored) {
+ granted = false;
+ } finally {
+ if (process != null) process.destroy();
+ if (rootCheckProcess == process) rootCheckProcess = null;
+ }
+
+ boolean finalGranted = granted;
+ activity.runOnUiThread(() -> {
+ if (isDestroyed || appPreferences == null
+ || appPreferences.getExecutionMode() != ExecutionMode.ROOT) return;
+ if (finalGranted) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_root_authorized), 1);
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ } else {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_root_denied), 2);
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_ROOT);
+ android.widget.Toast.makeText(activity, activity.getString(R.string.toast_root_denied), android.widget.Toast.LENGTH_LONG).show();
+ }
+ TileService.requestListeningState(activity, new ComponentName(activity, NetworkTileService.class));
+ });
+ });
+ rootCheckThread.start();
+ }
+
+ public void checkShizukuPermission(boolean requestIfNeeded) {
+ if (isDestroyed) return;
+ try {
+ if (!Shizuku.pingBinder()) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_not_running), 2);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ TileService.requestListeningState(activity, new ComponentName(activity, NetworkTileService.class));
+ }
+ return;
+ }
+ if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_authorized), 1);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ TileService.requestListeningState(activity, new ComponentName(activity, NetworkTileService.class));
+ }
+ return;
+ }
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_not_granted), 2);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ TileService.requestListeningState(activity, new ComponentName(activity, NetworkTileService.class));
+ }
+ if (requestIfNeeded) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_requesting), 3);
+ Shizuku.requestPermission(0);
+ }
+ } catch (Exception e) {
+ statusCallback.onStatusUpdate(activity.getString(R.string.status_shizuku_check_failed), 2);
+ if (appPreferences != null) {
+ appPreferences.setTileErrorState(AppPreferences.TILE_ERROR_SHIZUKU);
+ TileService.requestListeningState(activity, new ComponentName(activity, NetworkTileService.class));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/GuidesTabHelper.java b/app/src/main/java/com/dhangofa/networktoggle/ui/GuidesTabHelper.java
new file mode 100644
index 00000000..d4cfd746
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/GuidesTabHelper.java
@@ -0,0 +1,83 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.res.ColorStateList;
+import android.net.Uri;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.dhangofa.networktoggle.R;
+
+public class GuidesTabHelper {
+
+ public static void setupTab(Activity activity) {
+ setupGuideTile(activity, R.id.guideExecution, R.drawable.ic_terminal, activity.getString(R.string.guide_title_execution_mode), activity.getString(R.string.guide_desc_execution_mode), "https://github.com/Dhangofa/NetToggle/wiki/1.-Execution-Mode-Configuration", R.color.carousel_page1_bg, R.color.carousel_page1_accent);
+ setupGuideTile(activity, R.id.guideSim, R.drawable.ic_sim_card, activity.getString(R.string.guide_title_target_sim), activity.getString(R.string.guide_desc_target_sim), "https://github.com/Dhangofa/NetToggle/wiki/2.-Target-SIM-Setup-&-Quick-Tile-Cycle-Guide", R.color.carousel_page2_bg, R.color.carousel_page2_accent);
+ setupGuideTile(activity, R.id.guideTile, R.drawable.ic_quick_tile, activity.getString(R.string.guide_title_quick_tile), activity.getString(R.string.guide_desc_quick_tile), "https://github.com/Dhangofa/NetToggle/wiki/3.-Adding-the-Tile-to-Quick-Settings", R.color.carousel_page3_bg, R.color.carousel_page3_accent);
+ setupGuideTile(activity, R.id.guideShortcuts, R.drawable.ic_magic_wand, activity.getString(R.string.guide_title_app_shortcuts), activity.getString(R.string.guide_desc_app_shortcuts), "https://github.com/Dhangofa/NetToggle/wiki/4.-App-Shortcuts-&-Built%E2%80%90in-OS-Routines", R.color.carousel_page4_bg, R.color.carousel_page4_accent);
+ setupGuideTile(activity, R.id.guideBroadcast, R.drawable.ic_broadcast, activity.getString(R.string.guide_title_broadcasts), activity.getString(R.string.guide_desc_broadcasts), "https://github.com/Dhangofa/NetToggle/wiki/5.-Broadcast-Automation-(Tasker,-MacroDroid,-Automate)", R.color.carousel_page5_bg, R.color.carousel_page5_accent);
+ setupGuideTile(activity, R.id.guideTroubleshoot, R.drawable.ic_help_outline, activity.getString(R.string.guide_title_troubleshooting), activity.getString(R.string.guide_desc_troubleshooting), "https://github.com/Dhangofa/NetToggle/wiki/Frequently-Asked-Questions-(FAQ)", R.color.carousel_page6_bg, R.color.carousel_page6_accent);
+ }
+
+ private static void setupGuideTile(Activity activity, int rootId, int iconRes, String title, String desc, String url, int bgTintRes, int accentRes) {
+ View root = activity.findViewById(rootId);
+ if (root == null) {
+ return;
+ }
+ ImageView icon = root.findViewById(R.id.tileIcon);
+ TextView titleText = root.findViewById(R.id.tileTitle);
+ TextView descText = root.findViewById(R.id.tileDesc);
+ View btn = root.findViewById(R.id.tileBtn);
+ TextView btnText = root.findViewById(R.id.tileBtnText);
+ ImageView btnIcon = root.findViewById(R.id.tileBtnIcon);
+
+ int accentColor = activity.getColor(accentRes);
+ int bgColor = activity.getColor(bgTintRes);
+
+ if (icon != null) {
+ icon.setImageResource(iconRes);
+ icon.setColorFilter(accentColor);
+ }
+ if (titleText != null) {
+ titleText.setText(title);
+ }
+ if (descText != null) {
+ descText.setText(desc);
+ }
+ if (btn != null) {
+ btn.setBackgroundTintList(ColorStateList.valueOf(bgColor));
+ btn.setOnClickListener(v -> openUrl(activity, url));
+ }
+ if (btnText != null) {
+ btnText.setTextColor(accentColor);
+ }
+ if (btnIcon != null) {
+ btnIcon.setColorFilter(accentColor);
+ }
+ root.setOnClickListener(v -> {
+ v.animate()
+ .scaleX(1.04f)
+ .scaleY(1.04f)
+ .setDuration(90)
+ .withEndAction(() -> {
+ v.animate()
+ .scaleX(1.0f)
+ .scaleY(1.0f)
+ .setDuration(120)
+ .start();
+ })
+ .start();
+ });
+ }
+
+ public static void openUrl(Activity activity, String url) {
+ try {
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ activity.startActivity(browserIntent);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/MainNavigationController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/MainNavigationController.java
new file mode 100644
index 00000000..7ba2f6ed
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/MainNavigationController.java
@@ -0,0 +1,1119 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.util.TypedValue;
+import android.view.Surface;
+import android.os.Build;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.OrientationEventListener;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.animation.DecelerateInterpolator;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.HorizontalScrollView;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RadioGroup;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import com.dhangofa.networktoggle.MainActivity;
+import com.dhangofa.networktoggle.R;
+
+public class MainNavigationController {
+ private final MainActivity activity;
+ private int currentTabIndex = 0;
+ private int savedAutomationSubTab = -1;
+ private View bottomNavPill;
+ private View navIndicatorPill;
+ private View mainContentFrame;
+ private float currentScrollOffset = 0f;
+ private ValueAnimator pageScrollAnimator;
+ private boolean isDraggingPage = false;
+ private VelocityTracker velocityTracker;
+ private int touchSlop = 0;
+ private boolean isSwipeDisabledForCurrentTouch = false;
+ private float touchDownX = 0f;
+ private float touchDownY = 0f;
+
+ private View tabHome;
+ private View tabAutomation;
+ private View tabGuides;
+
+ private LinearLayout navHome;
+ private LinearLayout navAutomation;
+ private LinearLayout navGuides;
+ private TextView textNavHome;
+ private TextView textNavAutomation;
+ private TextView textNavGuides;
+ private ImageView iconNavHome;
+ private ImageView iconNavAutomation;
+ private ImageView iconNavGuides;
+
+ private final Handler keyboardHandler = new Handler(Looper.getMainLooper());
+ private Runnable showNavPillRunnable;
+ private boolean isKeyboardCurrentlyOpen = false;
+
+ private int fullTextWidthHome = -1;
+ private int fullTextWidthAuto = -1;
+ private int fullTextWidthGuides = -1;
+ private int fullMarginStart = -1;
+ private int baseNavWidth = -1;
+
+ private ViewTreeObserver.OnGlobalLayoutListener keyboardLayoutListener;
+ private final int[] reusableTouchLoc = new int[2];
+ private OrientationEventListener orientationListener;
+ private int lastOrientationRotation = -1;
+ private boolean isDestroyed = false;
+
+ public MainNavigationController(MainActivity activity, int savedTab, int savedAutomationSubTab) {
+ this.activity = activity;
+ this.currentTabIndex = savedTab;
+ this.savedAutomationSubTab = savedAutomationSubTab;
+ }
+
+
+
+ public int getCurrentTabIndex() {
+ return currentTabIndex;
+ }
+
+
+ public void destroy() {
+ MainNavigationController.this.isDestroyed = true;
+ if (this.showNavPillRunnable != null) {
+ this.keyboardHandler.removeCallbacks(this.showNavPillRunnable);
+ this.showNavPillRunnable = null;
+ }
+ if (this.keyboardLayoutListener != null) {
+ View mainRoot = activity.findViewById(R.id.mainRoot);
+ if (mainRoot != null) {
+ mainRoot.getViewTreeObserver().removeOnGlobalLayoutListener(this.keyboardLayoutListener);
+ }
+ this.keyboardLayoutListener = null;
+ }
+ if (this.pageScrollAnimator != null && this.pageScrollAnimator.isRunning()) {
+ this.pageScrollAnimator.cancel();
+ }
+ if (this.velocityTracker != null) {
+ this.velocityTracker.recycle();
+ this.velocityTracker = null;
+ }
+ if (this.orientationListener != null) {
+ this.orientationListener.disable();
+ this.orientationListener = null;
+ }
+ }
+
+ public void initialize() {
+ this.bottomNavPill = MainNavigationController.this.activity.findViewById(R.id.bottomNavPill);
+ if (this.bottomNavPill == null) {
+ this.bottomNavPill = MainNavigationController.this.activity.findViewById(R.id.edgeBarContainer);
+ }
+ this.navIndicatorPill = MainNavigationController.this.activity.findViewById(R.id.navIndicatorPill);
+ this.mainContentFrame = MainNavigationController.this.activity.findViewById(R.id.mainContentFrame);
+ this.touchSlop = ViewConfiguration.get(MainNavigationController.this.activity).getScaledTouchSlop();
+ if (MainNavigationController.this.activity.isAmoled() && this.navIndicatorPill != null && this.navIndicatorPill.getBackground() instanceof GradientDrawable) {
+ ((GradientDrawable) this.navIndicatorPill.getBackground().mutate())
+ .setColor(MainNavigationController.this.activity.getColor(R.color.nav_item_selected_bg_amoled));
+ }
+ this.tabHome = MainNavigationController.this.activity.findViewById(R.id.tabHome);
+ this.tabAutomation = MainNavigationController.this.activity.findViewById(R.id.tabAutomation);
+ this.tabGuides = MainNavigationController.this.activity.findViewById(R.id.tabGuides);
+ this.navHome = (LinearLayout)MainNavigationController.this.activity.findViewById(R.id.navHome);
+ this.navAutomation = (LinearLayout)MainNavigationController.this.activity.findViewById(R.id.navAutomation);
+ this.navGuides = (LinearLayout)MainNavigationController.this.activity.findViewById(R.id.navGuides);
+ this.textNavHome = (TextView)MainNavigationController.this.activity.findViewById(R.id.textNavHome);
+ this.textNavAutomation = (TextView)MainNavigationController.this.activity.findViewById(R.id.textNavAutomation);
+ this.textNavGuides = (TextView)MainNavigationController.this.activity.findViewById(R.id.textNavGuides);
+ this.iconNavHome = (ImageView)MainNavigationController.this.activity.findViewById(R.id.iconNavHome);
+ this.iconNavAutomation = (ImageView)MainNavigationController.this.activity.findViewById(R.id.iconNavAutomation);
+ this.iconNavGuides = (ImageView)MainNavigationController.this.activity.findViewById(R.id.iconNavGuides);
+ if (this.navHome == null) {
+ return;
+ }
+ this.navHome.setOnClickListener(v -> this.switchTab(0));
+ this.navAutomation.setOnClickListener(v -> this.switchTab(1));
+ this.navGuides.setOnClickListener(v -> this.switchTab(2));
+ RadioGroup automationSegmentGroup = (RadioGroup)MainNavigationController.this.activity.findViewById(R.id.automationSegmentGroup);
+ View pageShortcuts = MainNavigationController.this.activity.findViewById(R.id.pageShortcuts);
+ View pageBroadcasts = MainNavigationController.this.activity.findViewById(R.id.pageBroadcasts);
+ if (automationSegmentGroup != null) {
+ if (this.savedAutomationSubTab != -1) {
+ automationSegmentGroup.check(this.savedAutomationSubTab);
+ }
+ int initialCheckedId = automationSegmentGroup.getCheckedRadioButtonId();
+ if (pageShortcuts != null) {
+ pageShortcuts.setVisibility(initialCheckedId == R.id.radioShortcuts ? View.VISIBLE : View.GONE);
+ }
+ if (pageBroadcasts != null) {
+ pageBroadcasts.setVisibility(initialCheckedId == R.id.radioBroadcasts ? View.VISIBLE : View.GONE);
+ }
+ automationSegmentGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (pageShortcuts != null) {
+ pageShortcuts.setVisibility(checkedId == R.id.radioShortcuts ? View.VISIBLE : View.GONE);
+ }
+ if (pageBroadcasts != null) {
+ pageBroadcasts.setVisibility(checkedId == R.id.radioBroadcasts ? View.VISIBLE : View.GONE);
+ }
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().updateFabVisibility();
+ }
+ });
+ }
+
+ if (MainNavigationController.this.isLandscape()) {
+ MainNavigationController.this.updateLandscapeEdgeNavProgress((float) this.currentTabIndex);
+ } else {
+ this.ensureNavDimensions();
+ this.updateNavPillProgress((float) this.currentTabIndex);
+ }
+ if (this.bottomNavPill != null) {
+ this.bottomNavPill.post(() -> {
+ if (MainNavigationController.this.isLandscape()) {
+ MainNavigationController.this.updateLandscapeEdgeNavProgress((float) this.currentTabIndex);
+ } else {
+ this.ensureNavDimensions();
+ this.updateNavPillProgress((float) this.currentTabIndex);
+ }
+ });
+ }
+
+ if (this.mainContentFrame != null) {
+ this.mainContentFrame.post(() -> {
+ if (MainNavigationController.this.isLandscape()) {
+ MainNavigationController.this.updateEdgeBarPosition();
+ if (this.tabHome != null) {
+ this.tabHome.setTranslationX(0f);
+ this.tabHome.setVisibility(this.currentTabIndex == 0 ? android.view.View.VISIBLE : android.view.View.GONE);
+ this.tabHome.setAlpha(this.currentTabIndex == 0 ? 1f : 0f);
+ }
+ if (this.tabAutomation != null) {
+ this.tabAutomation.setTranslationX(0f);
+ this.tabAutomation.setVisibility(this.currentTabIndex == 1 ? android.view.View.VISIBLE : android.view.View.GONE);
+ this.tabAutomation.setAlpha(this.currentTabIndex == 1 ? 1f : 0f);
+ }
+ if (this.tabGuides != null) {
+ this.tabGuides.setTranslationX(0f);
+ this.tabGuides.setVisibility(this.currentTabIndex == 2 ? android.view.View.VISIBLE : android.view.View.GONE);
+ this.tabGuides.setAlpha(this.currentTabIndex == 2 ? 1f : 0f);
+ }
+ MainNavigationController.this.updateLandscapeEdgeNavProgress((float) this.currentTabIndex);
+ MainNavigationController.this.onTabSettled(this.currentTabIndex);
+ } else {
+ int width = this.mainContentFrame.getWidth();
+ if (width <= 0) {
+ width = this.activity.getResources().getDisplayMetrics().widthPixels;
+ }
+ this.currentScrollOffset = (float) this.currentTabIndex * width;
+ MainNavigationController.this.applyScrollOffset(this.currentScrollOffset);
+ MainNavigationController.this.onTabSettled(this.currentTabIndex);
+ }
+ });
+ }
+ this.setupLandscapeHomeHeightSync();
+ }
+
+ public void setupLandscapeHomeHeightSync() {
+ if (!isLandscape()) {
+ return;
+ }
+ View tabHomeView = this.tabHome != null ? this.tabHome : this.activity.findViewById(R.id.tabHome);
+ if (tabHomeView == null) {
+ return;
+ }
+ View leftCol = tabHomeView.findViewById(R.id.homeLeftColumn);
+ View rightCol = tabHomeView.findViewById(R.id.homeRightColumn);
+ View cardTargetSim = tabHomeView.findViewById(R.id.cardTargetSim);
+ View cardTileCycle = tabHomeView.findViewById(R.id.cardTileCycle);
+ if (leftCol == null || rightCol == null || cardTileCycle == null) {
+ return;
+ }
+
+ tabHomeView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ if (!MainNavigationController.this.isLandscape() || MainNavigationController.this.isDestroyed) {
+ return;
+ }
+ int leftH = leftCol.getHeight();
+ int rightH = rightCol.getHeight();
+ int maxH = Math.max(leftH, rightH);
+ if (maxH <= 0) {
+ return;
+ }
+
+ int simH = cardTargetSim != null ? cardTargetSim.getHeight() : 0;
+ int margin = (int) (12 * MainNavigationController.this.activity.getResources().getDisplayMetrics().density);
+ int targetTileCycleH = maxH - simH - margin;
+ if (targetTileCycleH > 0 && cardTileCycle.getHeight() < targetTileCycleH) {
+ cardTileCycle.setMinimumHeight(targetTileCycleH);
+ }
+ }
+ });
+ }
+
+ public void setupKeyboardListener() {
+ View mainRoot = MainNavigationController.this.activity.findViewById(R.id.mainRoot);
+ if (mainRoot == null) {
+ return;
+ }
+
+ this.keyboardLayoutListener = () -> {
+ if (MainNavigationController.this.isDestroyed) {
+ return;
+ }
+ Rect r = new Rect();
+ mainRoot.getWindowVisibleDisplayFrame(r);
+ int screenHeight = mainRoot.getRootView().getHeight();
+ int keypadHeight = screenHeight - r.bottom;
+ boolean isKeyboardOpen = keypadHeight > screenHeight * 0.15;
+
+ if (isKeyboardOpen) {
+ if (!this.isKeyboardCurrentlyOpen) {
+ this.isKeyboardCurrentlyOpen = true;
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().setKeyboardOpen(true);
+ }
+ View btnAdd = MainNavigationController.this.activity.findViewById(R.id.btnAddShortcut);
+ if (btnAdd != null) {
+ btnAdd.animate().cancel();
+ btnAdd.setVisibility(View.GONE);
+ }
+ if (!MainNavigationController.this.isLandscape()) {
+ if (this.showNavPillRunnable != null) {
+ this.keyboardHandler.removeCallbacks(this.showNavPillRunnable);
+ this.showNavPillRunnable = null;
+ }
+ if (this.bottomNavPill != null) {
+ this.bottomNavPill.animate().cancel();
+ this.bottomNavPill.setVisibility(View.GONE);
+ }
+ }
+ }
+ } else {
+ if (this.isKeyboardCurrentlyOpen || (!MainNavigationController.this.isLandscape() && this.bottomNavPill != null && this.bottomNavPill.getVisibility() == View.GONE)) {
+ this.isKeyboardCurrentlyOpen = false;
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().setKeyboardOpen(false);
+ }
+ if (!MainNavigationController.this.isLandscape()) {
+ if (this.showNavPillRunnable != null) {
+ this.keyboardHandler.removeCallbacks(this.showNavPillRunnable);
+ }
+ this.showNavPillRunnable = () -> {
+ if (MainNavigationController.this.isDestroyed || this.bottomNavPill == null || this.isKeyboardCurrentlyOpen) {
+ return;
+ }
+ int offset = this.bottomNavPill.getHeight();
+ if (offset <= 0) {
+ offset = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 80, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ }
+ if (this.bottomNavPill.getVisibility() != View.VISIBLE) {
+ this.bottomNavPill.setVisibility(View.VISIBLE);
+ this.bottomNavPill.setTranslationY((float) offset);
+ this.bottomNavPill.setAlpha(0f);
+ this.bottomNavPill.animate()
+ .translationY(0f)
+ .alpha(1f)
+ .setDuration(220)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().updateFabVisibility();
+ }
+ View btnAdd = MainNavigationController.this.activity.findViewById(R.id.btnAddShortcut);
+ if (btnAdd != null && btnAdd.getVisibility() == View.VISIBLE) {
+ btnAdd.setTranslationY((float) offset);
+ btnAdd.setAlpha(0f);
+ btnAdd.animate()
+ .translationY(0f)
+ .alpha(1f)
+ .setDuration(220)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ };
+ // Delaying by 180ms allows the system IME dismiss animation and window layout to settle before the nav bar glides up
+ this.keyboardHandler.postDelayed(this.showNavPillRunnable, 180);
+ } else {
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().updateFabVisibility();
+ }
+ }
+ }
+ }
+ };
+ mainRoot.getViewTreeObserver().addOnGlobalLayoutListener(this.keyboardLayoutListener);
+ }
+
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (MainNavigationController.this.isLandscape()) {
+ return MainNavigationController.this.activity.superDispatchTouchEvent(ev);
+ }
+ if (this.mainContentFrame == null) {
+ return MainNavigationController.this.activity.superDispatchTouchEvent(ev);
+ }
+ int width = this.mainContentFrame.getWidth();
+ if (width <= 0) {
+ width = MainNavigationController.this.activity.getResources().getDisplayMetrics().widthPixels;
+ }
+
+ int action = ev.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ this.isSwipeDisabledForCurrentTouch = this.isTouchInsideInteractiveChild(ev.getRawX(), ev.getRawY());
+ android.util.Log.d("SwipeDebug", "ACTION_DOWN disabled=" + this.isSwipeDisabledForCurrentTouch + " x=" + ev.getRawX() + " y=" + ev.getRawY());
+
+ this.isDraggingPage = false;
+ this.touchDownX = ev.getRawX();
+ this.touchDownY = ev.getRawY();
+ if (this.velocityTracker == null) {
+ this.velocityTracker = VelocityTracker.obtain();
+ } else {
+ this.velocityTracker.clear();
+ }
+ this.velocityTracker.addMovement(ev);
+ if (this.pageScrollAnimator != null && this.pageScrollAnimator.isRunning()) {
+ this.pageScrollAnimator.cancel();
+ }
+ }
+
+ if (this.isSwipeDisabledForCurrentTouch) {
+ return MainNavigationController.this.activity.superDispatchTouchEvent(ev);
+ }
+
+ if (this.velocityTracker != null) {
+ this.velocityTracker.addMovement(ev);
+ }
+
+
+ if (action == MotionEvent.ACTION_MOVE) {
+ float deltaX = ev.getRawX() - this.touchDownX;
+ float deltaY = ev.getRawY() - this.touchDownY;
+ android.util.Log.d("SwipeDebug", "ACTION_MOVE deltaX=" + deltaX + " deltaY=" + deltaY + " dragging=" + this.isDraggingPage);
+
+
+ if (!this.isDraggingPage) {
+ if (Math.abs(deltaX) > this.touchSlop && Math.abs(deltaX) > Math.abs(deltaY) * 1.25f) {
+ boolean canDrag = true;
+ if (this.currentTabIndex == 0 && deltaX > 0) {
+ canDrag = false;
+ } else if (this.currentTabIndex == 2 && deltaX < 0) {
+ canDrag = false;
+ }
+
+
+
+ if (canDrag) {
+ android.util.Log.d("SwipeDebug", "DRAG START!");
+
+
+ this.isDraggingPage = true;
+ View currentFocus = MainNavigationController.this.activity.getCurrentFocus();
+ if (currentFocus != null) {
+ InputMethodManager imm = (InputMethodManager) MainNavigationController.this.activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(currentFocus.getWindowToken(), 0);
+ }
+ currentFocus.clearFocus();
+ }
+ MotionEvent cancelEvent = MotionEvent.obtain(ev);
+ cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
+ MainNavigationController.this.activity.superDispatchTouchEvent(cancelEvent);
+ cancelEvent.recycle();
+ }
+ }
+ }
+
+ if (this.isDraggingPage) {
+ float targetOffset = (float) this.currentTabIndex * width - deltaX;
+ targetOffset = Math.max(0f, Math.min(2.0f * width, targetOffset));
+ MainNavigationController.this.applyScrollOffset(targetOffset);
+ return true;
+ }
+ }
+
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ if (this.isDraggingPage) {
+ float xVelocity = 0f;
+ if (this.velocityTracker != null) {
+ this.velocityTracker.computeCurrentVelocity(1000);
+ xVelocity = this.velocityTracker.getXVelocity();
+ }
+
+ float currentPage = width > 0 ? (this.currentScrollOffset / (float) width) : (float) this.currentTabIndex;
+ int targetIndex = this.currentTabIndex;
+
+ if (Math.abs(xVelocity) > 800) {
+ if (xVelocity < 0 && this.currentTabIndex < 2) {
+ targetIndex = this.currentTabIndex + 1;
+ } else if (xVelocity > 0 && this.currentTabIndex > 0) {
+ targetIndex = this.currentTabIndex - 1;
+ }
+ } else {
+ targetIndex = Math.round(currentPage);
+ }
+
+ targetIndex = Math.max(0, Math.min(2, targetIndex));
+ this.animateScrollToTab(targetIndex);
+ this.isDraggingPage = false;
+ if (this.velocityTracker != null) {
+ this.velocityTracker.recycle();
+ this.velocityTracker = null;
+ }
+ return true;
+ }
+
+ if (this.velocityTracker != null) {
+ this.velocityTracker.recycle();
+ this.velocityTracker = null;
+ }
+ }
+
+ return MainNavigationController.this.activity.superDispatchTouchEvent(ev);
+ }
+
+ private void applyScrollOffset(float offset) {
+ if (this.mainContentFrame == null) {
+ return;
+ }
+ int width = this.mainContentFrame.getWidth();
+ if (width <= 0) {
+ width = MainNavigationController.this.activity.getResources().getDisplayMetrics().widthPixels;
+ }
+ this.currentScrollOffset = offset;
+ float pagePosition = width > 0 ? (offset / (float) width) : 0f;
+
+ // 1. Pages translation & visibility
+ if (this.tabHome != null) {
+ float tx = 0f - offset;
+ this.tabHome.setTranslationX(tx);
+ this.tabHome.setVisibility(Math.abs(tx) < width ? View.VISIBLE : View.INVISIBLE);
+ }
+ if (this.tabAutomation != null) {
+ float tx = (float) width - offset;
+ this.tabAutomation.setTranslationX(tx);
+ this.tabAutomation.setVisibility(Math.abs(tx) < width ? View.VISIBLE : View.INVISIBLE);
+ }
+ if (this.tabGuides != null) {
+ float tx = (float) (2 * width) - offset;
+ this.tabGuides.setTranslationX(tx);
+ this.tabGuides.setVisibility(Math.abs(tx) < width ? View.VISIBLE : View.INVISIBLE);
+ }
+
+ // 2. Synchronize sliding nav pill & icon/text colors
+ this.updateNavPillProgress(pagePosition);
+
+ // 3. In portrait mode, fade the FAB if we swipe away from the automation tab
+ if (!MainNavigationController.this.isLandscape() && MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ View btnAdd = MainNavigationController.this.activity.findViewById(R.id.btnAddShortcut);
+ if (btnAdd != null) {
+ float distFromAuto = Math.abs(offset - width);
+ if (distFromAuto > width * 0.4f) {
+ if (btnAdd.getVisibility() == View.VISIBLE) {
+ btnAdd.setVisibility(View.GONE);
+ }
+ } else if (MainNavigationController.this.activity.getShortcutTabHelper().isShortcutTabActive() && !this.isKeyboardCurrentlyOpen && MainNavigationController.this.activity.getShortcutTabHelper().getShortcutCount() < 4) {
+ float alpha = Math.max(0f, 1f - (distFromAuto / (width * 0.4f)));
+ btnAdd.setVisibility(View.VISIBLE);
+ btnAdd.setAlpha(alpha);
+ btnAdd.setScaleX(0.7f + 0.3f * alpha);
+ btnAdd.setScaleY(0.7f + 0.3f * alpha);
+ }
+ }
+ }
+ }
+
+ private void ensureNavDimensions() {
+ if (this.fullTextWidthHome > 0 && this.baseNavWidth > 0) {
+ return;
+ }
+ if (this.textNavHome == null || this.textNavAutomation == null || this.textNavGuides == null) {
+ return;
+ }
+
+ if (this.navHome != null && this.iconNavHome != null) {
+ int iconW = this.iconNavHome.getLayoutParams() != null && this.iconNavHome.getLayoutParams().width > 0
+ ? this.iconNavHome.getLayoutParams().width
+ : (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 22, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ int padL = this.navHome.getPaddingLeft() > 0
+ ? this.navHome.getPaddingLeft()
+ : (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ int padR = this.navHome.getPaddingRight() > 0
+ ? this.navHome.getPaddingRight()
+ : (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ this.baseNavWidth = padL + iconW + padR;
+ } else {
+ this.baseNavWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 50, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ }
+
+ ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) this.textNavHome.getLayoutParams();
+ if (mlp != null && mlp.getMarginStart() > 0) {
+ this.fullMarginStart = mlp.getMarginStart();
+ } else {
+ this.fullMarginStart = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6, MainNavigationController.this.activity.getResources().getDisplayMetrics());
+ }
+
+ this.fullTextWidthHome = this.measureTextFullWidth(this.textNavHome);
+ this.fullTextWidthAuto = this.measureTextFullWidth(this.textNavAutomation);
+ this.fullTextWidthGuides = this.measureTextFullWidth(this.textNavGuides);
+ }
+
+ private int measureTextFullWidth(TextView tv) {
+ if (tv == null) {
+ return 0;
+ }
+ tv.measure(
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ );
+ int m = tv.getMeasuredWidth();
+ if (m <= 0) {
+ CharSequence cs = tv.getText();
+ String s = cs != null ? cs.toString() : "";
+ m = (int) Math.ceil(tv.getPaint().measureText(s)) + tv.getPaddingLeft() + tv.getPaddingRight();
+ }
+ return Math.max(m, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30, MainNavigationController.this.activity.getResources().getDisplayMetrics()));
+ }
+
+ private void updateNavPillProgress(float pagePosition) {
+ if (this.navIndicatorPill == null || this.navHome == null || this.navAutomation == null || this.navGuides == null) {
+ return;
+ }
+
+ this.ensureNavDimensions();
+
+ float clamped = Math.max(0f, Math.min(2f, pagePosition));
+ float w0;
+ float w1;
+ float w2;
+ if (clamped <= 1.0f) {
+ w0 = 1.0f - clamped;
+ w1 = clamped;
+ w2 = 0f;
+ } else {
+ w0 = 0f;
+ w1 = 2.0f - clamped;
+ w2 = clamped - 1.0f;
+ }
+
+ // Dynamically and smoothly adjust text widths, start margins, and alpha on every frame
+ this.updateTabLabel(this.textNavHome, w0, this.fullTextWidthHome, this.fullMarginStart);
+ this.updateTabLabel(this.textNavAutomation, w1, this.fullTextWidthAuto, this.fullMarginStart);
+ this.updateTabLabel(this.textNavGuides, w2, this.fullTextWidthGuides, this.fullMarginStart);
+
+ // Dynamically compute exact item widths as they smoothly morph without sudden jumps
+ int item0Width = this.baseNavWidth + Math.round((this.fullMarginStart + this.fullTextWidthHome) * w0);
+ int item1Width = this.baseNavWidth + Math.round((this.fullMarginStart + this.fullTextWidthAuto) * w1);
+ int item2Width = this.baseNavWidth + Math.round((this.fullMarginStart + this.fullTextWidthGuides) * w2);
+
+ float targetX;
+ float targetW;
+ if (clamped <= 1.0f) {
+ targetX = clamped * (float) item0Width;
+ targetW = (float) item0Width + clamped * (float) (item1Width - item0Width);
+ } else {
+ float f = clamped - 1.0f;
+ targetX = (float) item0Width + f * (float) item1Width;
+ targetW = (float) item1Width + f * (float) (item2Width - item1Width);
+ }
+
+ this.navIndicatorPill.setTranslationX(targetX);
+ ViewGroup.LayoutParams lp = this.navIndicatorPill.getLayoutParams();
+ int roundedTargetW = Math.round(targetW);
+ if (lp != null && lp.width != roundedTargetW) {
+ lp.width = roundedTargetW;
+ this.navIndicatorPill.setLayoutParams(lp);
+ }
+
+ boolean isAmoled = MainNavigationController.this.activity.isAmoled();
+ int selectedColor = MainNavigationController.this.activity.getColor(isAmoled ? R.color.nav_item_selected_content_amoled : R.color.nav_item_selected_content);
+ int unselectedColor = MainNavigationController.this.activity.getColor(isAmoled ? R.color.nav_item_unselected_icon_amoled : R.color.nav_item_unselected_icon);
+
+ int c0 = this.blendColors(unselectedColor, selectedColor, w0);
+ int c1 = this.blendColors(unselectedColor, selectedColor, w1);
+ int c2 = this.blendColors(unselectedColor, selectedColor, w2);
+
+ if (this.iconNavHome != null) {
+ this.iconNavHome.setColorFilter(c0);
+ this.iconNavHome.setImageResource(w0 > 0.5f ? R.drawable.ic_nav_home_filled : R.drawable.ic_nav_home_outline);
+ }
+ if (this.textNavHome != null) {
+ this.textNavHome.setTextColor(c0);
+ }
+
+ if (this.iconNavAutomation != null) {
+ this.iconNavAutomation.setColorFilter(c1);
+ this.iconNavAutomation.setImageResource(w1 > 0.5f ? R.drawable.ic_nav_automate_filled : R.drawable.ic_nav_automate_outline);
+ }
+ if (this.textNavAutomation != null) {
+ this.textNavAutomation.setTextColor(c1);
+ }
+
+ if (this.iconNavGuides != null) {
+ this.iconNavGuides.setColorFilter(c2);
+ this.iconNavGuides.setImageResource(w2 > 0.5f ? R.drawable.ic_nav_guides_filled : R.drawable.ic_nav_guides_outline);
+ }
+ if (this.textNavGuides != null) {
+ this.textNavGuides.setTextColor(c2);
+ }
+ }
+
+ private void updateTabLabel(TextView textView, float weight, int fullWidth, int fullMargin) {
+ if (textView == null) {
+ return;
+ }
+ ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) textView.getLayoutParams();
+ if (lp == null) {
+ return;
+ }
+
+ if (weight <= 0.005f) {
+ if (textView.getVisibility() != View.GONE) {
+ textView.setVisibility(View.GONE);
+ }
+ if (lp.width != 0 || lp.getMarginStart() != 0) {
+ lp.width = 0;
+ lp.setMarginStart(0);
+ textView.setLayoutParams(lp);
+ }
+ } else {
+ int targetW = Math.round(fullWidth * weight);
+ int targetM = Math.round(fullMargin * weight);
+ if (lp.width != targetW || lp.getMarginStart() != targetM) {
+ lp.width = targetW;
+ lp.setMarginStart(targetM);
+ textView.setLayoutParams(lp);
+ }
+ if (textView.getVisibility() != View.VISIBLE) {
+ textView.setVisibility(View.VISIBLE);
+ }
+ textView.setAlpha(weight);
+ }
+ }
+
+ private int blendColors(int from, int to, float ratio) {
+ float clamped = Math.max(0f, Math.min(1f, ratio));
+ float inv = 1f - clamped;
+ float a = Color.alpha(from) * inv + Color.alpha(to) * clamped;
+ float r = Color.red(from) * inv + Color.red(to) * clamped;
+ float g = Color.green(from) * inv + Color.green(to) * clamped;
+ float b = Color.blue(from) * inv + Color.blue(to) * clamped;
+ return Color.argb((int) a, (int) r, (int) g, (int) b);
+ }
+
+ private void animateScrollToTab(int targetIndex) {
+ int width = this.mainContentFrame != null ? this.mainContentFrame.getWidth() : 0;
+ if (width <= 0) {
+ width = MainNavigationController.this.activity.getResources().getDisplayMetrics().widthPixels;
+ }
+ final float endOffset = (float) targetIndex * width;
+ final float startOffset = this.currentScrollOffset;
+
+ if (Math.abs(startOffset - endOffset) < 1f) {
+ MainNavigationController.this.currentTabIndex = targetIndex;
+ MainNavigationController.this.onTabSettled(targetIndex);
+ MainNavigationController.this.applyScrollOffset(endOffset);
+ return;
+ }
+
+ if (this.pageScrollAnimator != null && this.pageScrollAnimator.isRunning()) {
+ this.pageScrollAnimator.cancel();
+ }
+
+ this.pageScrollAnimator = ValueAnimator.ofFloat(startOffset, endOffset);
+ this.pageScrollAnimator.setDuration(280);
+ this.pageScrollAnimator.setInterpolator(new DecelerateInterpolator(1.5f));
+ this.pageScrollAnimator.addUpdateListener(animation -> {
+ float val = (Float) animation.getAnimatedValue();
+ MainNavigationController.this.applyScrollOffset(val);
+ });
+ this.pageScrollAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ MainNavigationController.this.currentTabIndex = targetIndex;
+ MainNavigationController.this.onTabSettled(targetIndex);
+ MainNavigationController.this.applyScrollOffset(endOffset);
+ }
+ });
+ this.pageScrollAnimator.start();
+ }
+
+ private void onTabSettled(int index) {
+ if (MainNavigationController.this.isLandscape()) {
+ MainNavigationController.this.updateLandscapeEdgeNavProgress((float) index);
+ for (int i = 0; i < 3; i++) {
+ View v = this.getTabViewByIndex(i);
+ if (v != null) {
+ if (i == index) {
+ v.setVisibility(View.VISIBLE);
+ v.setAlpha(1f);
+ } else {
+ v.setVisibility(View.GONE);
+ v.setAlpha(0f);
+ }
+ }
+ }
+ } else {
+ this.updateNavPillProgress((float) index);
+ }
+ if (index == 1) {
+ if (MainNavigationController.this.activity.getBroadcastTabHelper() != null) {
+ MainNavigationController.this.activity.getBroadcastTabHelper().refreshCapabilities();
+ }
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().setAuthorized(MainNavigationController.this.activity.isExecutionAuthorized());
+ MainNavigationController.this.activity.getShortcutTabHelper().updateFabVisibility();
+ }
+ } else {
+ if (MainNavigationController.this.activity.getShortcutTabHelper() != null) {
+ MainNavigationController.this.activity.getShortcutTabHelper().updateFabVisibility();
+ }
+ }
+ }
+
+ public void switchTab(int index) {
+ View currentFocus = MainNavigationController.this.activity.getCurrentFocus();
+ if (currentFocus != null) {
+ InputMethodManager imm = (InputMethodManager) MainNavigationController.this.activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(currentFocus.getWindowToken(), 0);
+ }
+ currentFocus.clearFocus();
+ }
+ if (MainNavigationController.this.isLandscape()) {
+ this.switchTabLandscape(index);
+ } else {
+ this.animateScrollToTab(index);
+ }
+ }
+
+ private boolean isLandscape() {
+ return MainNavigationController.this.activity.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ public void setupOrientationListener() {
+ this.orientationListener = new OrientationEventListener(MainNavigationController.this.activity) {
+ @Override
+ public void onOrientationChanged(int orientation) {
+ if (!MainNavigationController.this.isLandscape() || MainNavigationController.this.isDestroyed) {
+ return;
+ }
+ int currentRotation = Surface.ROTATION_0;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ if (MainNavigationController.this.activity.getDisplay() != null) {
+ currentRotation = MainNavigationController.this.activity.getDisplay().getRotation();
+ }
+ } else {
+ currentRotation = MainNavigationController.this.activity.getWindowManager().getDefaultDisplay().getRotation();
+ }
+ if (currentRotation != MainNavigationController.this.lastOrientationRotation) {
+ MainNavigationController.this.lastOrientationRotation = currentRotation;
+ MainNavigationController.this.updateEdgeBarPosition();
+ }
+ }
+ };
+ if (this.orientationListener.canDetectOrientation()) {
+ this.orientationListener.enable();
+ }
+ }
+
+ private void updateEdgeBarPosition() {
+ if (!MainNavigationController.this.isLandscape()) {
+ return;
+ }
+ LinearLayout mainRoot = MainNavigationController.this.activity.findViewById(R.id.mainRoot);
+ View edgeBarContainer = MainNavigationController.this.activity.findViewById(R.id.edgeBarContainer);
+ View mainContentWrapper = MainNavigationController.this.activity.findViewById(R.id.mainContentWrapper);
+ if (mainRoot == null || edgeBarContainer == null || mainContentWrapper == null) {
+ return;
+ }
+ int rotation = Surface.ROTATION_0;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ if (MainNavigationController.this.activity.getDisplay() != null) {
+ rotation = MainNavigationController.this.activity.getDisplay().getRotation();
+ }
+ } else {
+ rotation = MainNavigationController.this.activity.getWindowManager().getDefaultDisplay().getRotation();
+ }
+
+ boolean dockRight = (rotation == Surface.ROTATION_270);
+ int currentEdgeIndex = mainRoot.indexOfChild(edgeBarContainer);
+ int expectedEdgeIndex = dockRight ? 1 : 0;
+ if (currentEdgeIndex != expectedEdgeIndex && currentEdgeIndex != -1) {
+ mainRoot.removeView(edgeBarContainer);
+ mainRoot.removeView(mainContentWrapper);
+ if (dockRight) {
+ mainRoot.addView(mainContentWrapper);
+ mainRoot.addView(edgeBarContainer);
+ } else {
+ mainRoot.addView(edgeBarContainer);
+ mainRoot.addView(mainContentWrapper);
+ }
+ }
+ }
+
+ private View getTabViewByIndex(int index) {
+ if (index == 0) return this.tabHome;
+ if (index == 1) return this.tabAutomation;
+ if (index == 2) return this.tabGuides;
+ return null;
+ }
+
+ private void switchTabLandscape(int targetIndex) {
+ if (targetIndex == this.currentTabIndex) {
+ return;
+ }
+ final int fromIndex = this.currentTabIndex;
+ final int toIndex = targetIndex;
+ MainNavigationController.this.currentTabIndex = targetIndex;
+
+ if (this.pageScrollAnimator != null && this.pageScrollAnimator.isRunning()) {
+ this.pageScrollAnimator.cancel();
+ }
+
+ View toView = this.getTabViewByIndex(toIndex);
+
+ // Instantly hide and cancel animations on all non-target tabs
+ for (int i = 0; i < 3; i++) {
+ View v = this.getTabViewByIndex(i);
+ if (v != null) {
+ v.animate().cancel();
+ if (i != toIndex) {
+ v.setVisibility(View.GONE);
+ v.setAlpha(0f);
+ }
+ }
+ }
+
+ // Show and bring active tab to front
+ if (toView != null) {
+ toView.animate().cancel();
+ toView.setVisibility(View.VISIBLE);
+ toView.setAlpha(1f);
+ toView.setTranslationX(0f);
+ toView.setTranslationY(0f);
+ toView.bringToFront();
+ }
+
+ // Edge nav bar morphing & continuous progress
+ this.pageScrollAnimator = ValueAnimator.ofFloat((float) fromIndex, (float) toIndex);
+ this.pageScrollAnimator.setDuration(240);
+ this.pageScrollAnimator.setInterpolator(new DecelerateInterpolator(1.5f));
+ this.pageScrollAnimator.addUpdateListener(anim -> {
+ float p = (Float) anim.getAnimatedValue();
+ MainNavigationController.this.updateLandscapeEdgeNavProgress(p);
+ });
+ this.pageScrollAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ MainNavigationController.this.updateLandscapeEdgeNavProgress((float) toIndex);
+ MainNavigationController.this.onTabSettled(toIndex);
+ }
+ });
+ this.pageScrollAnimator.start();
+ }
+
+ private void updateLandscapeEdgeNavProgress(float progress) {
+ if (this.navIndicatorPill == null || this.navHome == null || this.navAutomation == null || this.navGuides == null) {
+ return;
+ }
+
+ float clamped = Math.max(0f, Math.min(2f, progress));
+ float w0;
+ float w1;
+ float w2;
+ if (clamped <= 1.0f) {
+ w0 = 1.0f - clamped;
+ w1 = clamped;
+ w2 = 0f;
+ } else {
+ w0 = 0f;
+ w1 = 2.0f - clamped;
+ w2 = clamped - 1.0f;
+ }
+
+ float density = MainNavigationController.this.activity.getResources().getDisplayMetrics().density;
+ int baseHeight = Math.round(48 * density);
+ int expandHeight = Math.round(16 * density);
+
+ int h0 = baseHeight + Math.round(expandHeight * w0);
+ int h1 = baseHeight + Math.round(expandHeight * w1);
+ int h2 = baseHeight + Math.round(expandHeight * w2);
+
+ this.updateVerticalTabLabel(this.textNavHome, w0);
+ this.updateVerticalTabLabel(this.textNavAutomation, w1);
+ this.updateVerticalTabLabel(this.textNavGuides, w2);
+
+ this.setItemHeight(this.navHome, h0);
+ this.setItemHeight(this.navAutomation, h1);
+ this.setItemHeight(this.navGuides, h2);
+
+ float targetY;
+ float targetH;
+ if (clamped <= 1.0f) {
+ targetY = clamped * (float) h0;
+ targetH = (float) h0 + clamped * (float) (h1 - h0);
+ } else {
+ float f = clamped - 1.0f;
+ targetY = (float) h0 + f * (float) h1;
+ targetH = (float) h1 + f * (float) (h2 - h1);
+ }
+
+ this.navIndicatorPill.setTranslationY(targetY);
+ ViewGroup.LayoutParams lp = this.navIndicatorPill.getLayoutParams();
+ int roundedH = Math.round(targetH);
+ if (lp != null && lp.height != roundedH) {
+ lp.height = roundedH;
+ this.navIndicatorPill.setLayoutParams(lp);
+ }
+
+ boolean isAmoled = MainNavigationController.this.activity.isAmoled();
+ int selectedColor = MainNavigationController.this.activity.getColor(isAmoled ? R.color.nav_item_selected_content_amoled : R.color.nav_item_selected_content);
+ int unselectedColor = MainNavigationController.this.activity.getColor(isAmoled ? R.color.nav_item_unselected_icon_amoled : R.color.nav_item_unselected_icon);
+
+ int c0 = this.blendColors(unselectedColor, selectedColor, w0);
+ int c1 = this.blendColors(unselectedColor, selectedColor, w1);
+ int c2 = this.blendColors(unselectedColor, selectedColor, w2);
+
+ if (this.iconNavHome != null) {
+ this.iconNavHome.setColorFilter(c0);
+ this.iconNavHome.setImageResource(w0 > 0.5f ? R.drawable.ic_nav_home_filled : R.drawable.ic_nav_home_outline);
+ }
+ if (this.textNavHome != null) {
+ this.textNavHome.setTextColor(c0);
+ }
+
+ if (this.iconNavAutomation != null) {
+ this.iconNavAutomation.setColorFilter(c1);
+ this.iconNavAutomation.setImageResource(w1 > 0.5f ? R.drawable.ic_nav_automate_filled : R.drawable.ic_nav_automate_outline);
+ }
+ if (this.textNavAutomation != null) {
+ this.textNavAutomation.setTextColor(c1);
+ }
+
+ if (this.iconNavGuides != null) {
+ this.iconNavGuides.setColorFilter(c2);
+ this.iconNavGuides.setImageResource(w2 > 0.5f ? R.drawable.ic_nav_guides_filled : R.drawable.ic_nav_guides_outline);
+ }
+ if (this.textNavGuides != null) {
+ this.textNavGuides.setTextColor(c2);
+ }
+ }
+
+ private int fullVerticalTextHeight = 0;
+
+ private void updateVerticalTabLabel(TextView textView, float weight) {
+ if (textView == null) {
+ return;
+ }
+ float density = MainNavigationController.this.activity.getResources().getDisplayMetrics().density;
+ if (this.fullVerticalTextHeight <= 0) {
+ this.fullVerticalTextHeight = Math.max(textView.getLineHeight(), Math.round(15 * density));
+ }
+
+ ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) textView.getLayoutParams();
+ if (mlp == null) return;
+
+ if (weight <= 0.01f) {
+ if (textView.getVisibility() != View.GONE) {
+ textView.setVisibility(View.GONE);
+ }
+ if (mlp.height != 0 || mlp.topMargin != 0) {
+ mlp.height = 0;
+ mlp.topMargin = 0;
+ textView.setLayoutParams(mlp);
+ }
+ } else {
+ if (textView.getVisibility() != View.VISIBLE) {
+ textView.setVisibility(View.VISIBLE);
+ }
+ int targetH = (weight >= 0.99f) ? ViewGroup.LayoutParams.WRAP_CONTENT : Math.round(this.fullVerticalTextHeight * weight);
+ int targetMargin = Math.round(2 * density * weight);
+ if (mlp.height != targetH || mlp.topMargin != targetMargin) {
+ mlp.height = targetH;
+ mlp.topMargin = targetMargin;
+ textView.setLayoutParams(mlp);
+ }
+ textView.setAlpha(weight);
+ textView.setScaleX(0.75f + 0.25f * weight);
+ textView.setScaleY(0.75f + 0.25f * weight);
+ }
+ }
+
+ private void setItemHeight(View view, int height) {
+ if (view == null) return;
+ ViewGroup.LayoutParams lp = view.getLayoutParams();
+ if (lp != null && lp.height != height) {
+ lp.height = height;
+ view.setLayoutParams(lp);
+ }
+ }
+
+ private boolean isTouchInsideInteractiveChild(float rawX, float rawY) {
+ if (this.isViewUnder(this.bottomNavPill, rawX, rawY)) {
+ return true;
+ }
+ View currentTabView = null;
+ if (this.currentTabIndex == 0) {
+ currentTabView = this.tabHome;
+ } else if (this.currentTabIndex == 1) {
+ currentTabView = this.tabAutomation;
+ } else if (this.currentTabIndex == 2) {
+ currentTabView = this.tabGuides;
+ }
+
+ return this.isInteractiveChildUnder(currentTabView, rawX, rawY);
+ }
+
+ private boolean isInteractiveChildUnder(View view, float rawX, float rawY) {
+ if (view == null || view.getVisibility() != View.VISIBLE) {
+ return false;
+ }
+ view.getLocationOnScreen(this.reusableTouchLoc);
+ if (rawX < this.reusableTouchLoc[0] || rawX > this.reusableTouchLoc[0] + view.getWidth() ||
+ rawY < this.reusableTouchLoc[1] || rawY > this.reusableTouchLoc[1] + view.getHeight()) {
+ return false;
+ }
+ if (view instanceof EditText || view instanceof HorizontalScrollView || view instanceof Spinner) {
+ return true;
+ }
+ if (view instanceof ViewGroup) {
+ ViewGroup group = (ViewGroup) view;
+ for (int i = 0; i < group.getChildCount(); i++) {
+ if (this.isInteractiveChildUnder(group.getChildAt(i), rawX, rawY)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isViewUnder(View view, float rawX, float rawY) {
+ if (view == null || view.getVisibility() != View.VISIBLE) {
+ return false;
+ }
+ int[] loc = new int[2];
+ view.getLocationOnScreen(loc);
+ return rawX >= loc[0] && rawX <= loc[0] + view.getWidth() &&
+ rawY >= loc[1] && rawY <= loc[1] + view.getHeight();
+ }
+
+
+
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/PhoneStatePermissionManager.java b/app/src/main/java/com/dhangofa/networktoggle/ui/PhoneStatePermissionManager.java
new file mode 100644
index 00000000..0c40894c
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/PhoneStatePermissionManager.java
@@ -0,0 +1,161 @@
+package com.dhangofa.networktoggle.ui;
+
+/**
+ * Manager to handle the READ_PHONE_STATE permission flow.
+ * It tracks SDK-version checks, shows the rationale bottom-sheet UI, handles permanent denial,
+ * and routes the onRequestPermissionsResult cleanly without recursion loops.
+ */
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.Settings;
+import android.util.Log;
+
+import com.dhangofa.networktoggle.config.AppPreferences;
+
+public class PhoneStatePermissionManager {
+
+ private final Activity activity;
+ private final int reqCode;
+ private final AppPreferences prefs;
+ private final Runnable onGranted;
+ private Dialog permissionDialog;
+ private boolean activityDestroyed;
+ private boolean isGrantedCallbackDispatched;
+
+ public PhoneStatePermissionManager(Activity activity, int reqCode, Runnable onGranted) {
+ this(activity, reqCode, new AppPreferences(activity), onGranted);
+ }
+
+ public PhoneStatePermissionManager(Activity activity, int reqCode, AppPreferences prefs, Runnable onGranted) {
+ this.activity = activity;
+ this.reqCode = reqCode;
+ this.prefs = prefs;
+ this.onGranted = onGranted;
+ this.activityDestroyed = false;
+ this.isGrantedCallbackDispatched = false;
+ }
+
+ public boolean isPermissionGranted() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return activity.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
+ }
+ return true;
+ }
+
+ public void requestPermissionDirectly() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (!isPermissionGranted()) {
+ boolean shouldShowRationale = activity.shouldShowRequestPermissionRationale(android.Manifest.permission.READ_PHONE_STATE);
+ if (shouldShowRationale) {
+ showPermissionBottomSheet(false);
+ } else if (!prefs.hasRequestedPhonePermission()) {
+ prefs.setPhonePermissionRequested(true);
+ activity.requestPermissions(new String[]{android.Manifest.permission.READ_PHONE_STATE}, reqCode);
+ } else {
+ showPermissionBottomSheet(true);
+ }
+ }
+ }
+ }
+
+ public void checkAndRequest() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ boolean granted = activity.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
+ if (!granted) {
+ isGrantedCallbackDispatched = false;
+ boolean shouldShowRationale = activity.shouldShowRequestPermissionRationale(android.Manifest.permission.READ_PHONE_STATE);
+ if (shouldShowRationale) {
+ showPermissionBottomSheet(false);
+ } else if (!prefs.hasRequestedPhonePermission()) {
+ prefs.setPhonePermissionRequested(true);
+ activity.requestPermissions(new String[]{android.Manifest.permission.READ_PHONE_STATE}, reqCode);
+ } else {
+ showPermissionBottomSheet(true);
+ }
+ } else {
+ if (permissionDialog != null && permissionDialog.isShowing()) {
+ permissionDialog.dismiss();
+ }
+ if (!isGrantedCallbackDispatched && onGranted != null) {
+ isGrantedCallbackDispatched = true;
+ onGranted.run();
+ }
+ }
+ } else {
+ if (!isGrantedCallbackDispatched && onGranted != null) {
+ isGrantedCallbackDispatched = true;
+ onGranted.run();
+ }
+ }
+ }
+
+ private void showPermissionBottomSheet(boolean isPermanentlyDenied) {
+ if (activityDestroyed || activity.isFinishing()) return;
+
+ if (permissionDialog != null && permissionDialog.isShowing()) {
+ return;
+ }
+
+ permissionDialog = DialogHelper.buildPermissionBottomSheet(
+ activity,
+ isPermanentlyDenied,
+ () -> {
+ if (isPermanentlyDenied) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.fromParts("package", activity.getPackageName(), null));
+ activity.startActivity(intent);
+ } catch (Exception e) {
+ Log.e("PhoneStatePermission", "Failed to open application settings", e);
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ prefs.setPhonePermissionRequested(true);
+ activity.requestPermissions(new String[]{android.Manifest.permission.READ_PHONE_STATE}, reqCode);
+ }
+ }
+ },
+ null
+ );
+
+ if (!activityDestroyed && !activity.isFinishing()) {
+ permissionDialog.show();
+ }
+ }
+
+ public void handleRequestPermissionsResult(int requestCode, int[] grantResults) {
+ if (requestCode == reqCode) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (permissionDialog != null && permissionDialog.isShowing()) {
+ permissionDialog.dismiss();
+ }
+ isGrantedCallbackDispatched = true;
+ if (onGranted != null) {
+ onGranted.run();
+ }
+ } else {
+ isGrantedCallbackDispatched = false;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ boolean shouldShowRationale = activity.shouldShowRequestPermissionRationale(android.Manifest.permission.READ_PHONE_STATE);
+ if (shouldShowRationale) {
+ showPermissionBottomSheet(false);
+ } else {
+ showPermissionBottomSheet(true);
+ }
+ }
+ }
+ }
+ }
+
+ public void destroy() {
+ activityDestroyed = true;
+ if (permissionDialog != null && permissionDialog.isShowing()) {
+ permissionDialog.dismiss();
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/ShortcutTabHelper.java b/app/src/main/java/com/dhangofa/networktoggle/ui/ShortcutTabHelper.java
new file mode 100644
index 00000000..fb271bb3
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/ShortcutTabHelper.java
@@ -0,0 +1,662 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.content.res.Configuration;
+import android.graphics.drawable.Icon;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.GridLayout;
+import android.widget.ImageView;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.dhangofa.networktoggle.MainActivity;
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.ShortcutActionActivity;
+import com.dhangofa.networktoggle.automation.AutomationExecutor;
+import com.dhangofa.networktoggle.automation.AutomationRequest;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.model.TargetSim;
+import com.dhangofa.networktoggle.util.AppExecutors;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+public class ShortcutTabHelper {
+
+ private final Activity activity;
+ private final AppPreferences prefs;
+ private final List rows = new ArrayList<>();
+ private final List simDisplayList = new ArrayList<>();
+ private final List simValueList = new ArrayList<>();
+ private boolean isAuthorized = false;
+ private boolean isRestoringState = false;
+ private String lastSyncedSignature = null;
+ private boolean isKeyboardOpen = false;
+ private final Handler titleDebounceHandler = new Handler(Looper.getMainLooper());
+ private Runnable pendingTitleSync = null;
+ private Runnable syncShortcutsRunnable = null;
+
+ public ShortcutTabHelper(Activity activity, AppPreferences prefs) {
+ this.activity = activity;
+ this.prefs = prefs;
+ if (activity instanceof MainActivity) {
+ this.isAuthorized = ((MainActivity) activity).isExecutionAuthorized();
+ }
+ }
+
+ public static ShortcutTabHelper setupTab(Activity activity, AppPreferences prefs) {
+ ShortcutTabHelper helper = new ShortcutTabHelper(activity, prefs);
+ helper.setup();
+ return helper;
+ }
+
+ public void setAuthorized(boolean authorized) {
+ this.isAuthorized = authorized;
+ float alpha = authorized ? 1.0f : 0.4f;
+ for (View row : rows) {
+ View btnTestShortcut = row.findViewById(R.id.btnTestShortcut);
+ if (btnTestShortcut != null) {
+ btnTestShortcut.setAlpha(alpha);
+ }
+ }
+ refreshCapabilities();
+ }
+
+ private void scheduleDebouncedSync(Runnable syncShortcuts) {
+ if (pendingTitleSync != null) {
+ titleDebounceHandler.removeCallbacks(pendingTitleSync);
+ }
+ pendingTitleSync = () -> {
+ pendingTitleSync = null;
+ syncShortcuts.run();
+ };
+ titleDebounceHandler.postDelayed(pendingTitleSync, 400);
+ }
+
+ private void rebuildSimLists() {
+ simDisplayList.clear();
+ simValueList.clear();
+
+ boolean hasSim1 = false;
+ boolean hasSim2 = false;
+
+ if (activity.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
+ SubscriptionManager sm = activity.getSystemService(SubscriptionManager.class);
+ if (sm != null) {
+ try {
+ List infos = sm.getActiveSubscriptionInfoList();
+ if (infos != null) {
+ for (SubscriptionInfo info : infos) {
+ if (info.getSimSlotIndex() == 0) hasSim1 = true;
+ if (info.getSimSlotIndex() == 1) hasSim2 = true;
+ }
+ }
+ } catch (Exception ignored) {}
+ }
+ } else {
+ hasSim1 = true;
+ hasSim2 = true;
+ }
+
+ if (!hasSim1 && !hasSim2) {
+ hasSim1 = true;
+ }
+
+ if (hasSim1) {
+ simDisplayList.add(activity.getString(R.string.sim_1));
+ simValueList.add(1);
+ }
+ if (hasSim2) {
+ simDisplayList.add(activity.getString(R.string.sim_2));
+ simValueList.add(2);
+ }
+ if (hasSim1 && hasSim2) {
+ simDisplayList.add(activity.getString(R.string.both));
+ simValueList.add(3);
+ }
+ }
+
+ private void updateModesForRow(View row, String preferredMode) {
+ Spinner spinnerSim = row.findViewById(R.id.spinnerSim);
+ Spinner spinnerMode = row.findViewById(R.id.spinnerMode);
+ if (spinnerSim == null || spinnerMode == null) return;
+
+ int simPos = spinnerSim.getSelectedItemPosition();
+ int simValue = (simPos >= 0 && simPos < simValueList.size()) ? simValueList.get(simPos) : 1;
+
+ AppPreferences.NetworkCapabilities deviceCaps = prefs.getDeviceCapabilities();
+ if (deviceCaps == null) deviceCaps = AppPreferences.NetworkCapabilities.assumeAll();
+ AppPreferences.NetworkCapabilities sim1Caps = prefs.getSlotCapabilities(0);
+ if (sim1Caps == null) sim1Caps = deviceCaps;
+ AppPreferences.NetworkCapabilities sim2Caps = prefs.getSlotCapabilities(1);
+ if (sim2Caps == null) sim2Caps = deviceCaps;
+
+ AppPreferences.NetworkCapabilities caps;
+ if (simValue == 1) {
+ caps = sim1Caps;
+ } else if (simValue == 2) {
+ caps = sim2Caps;
+ } else {
+ caps = new AppPreferences.NetworkCapabilities(
+ sim1Caps.supports2g && sim2Caps.supports2g,
+ sim1Caps.supports3g && sim2Caps.supports3g,
+ sim1Caps.supports4g && sim2Caps.supports4g,
+ sim1Caps.supports5g && sim2Caps.supports5g
+ );
+ }
+
+ List validModes = new ArrayList<>();
+ List validDisplays = new ArrayList<>();
+
+ if (caps.supports5g) {
+ validModes.add("5G_ONLY"); validDisplays.add(activity.getString(R.string.text_5g_only));
+ validModes.add("PREF_5G"); validDisplays.add(activity.getString(R.string.pref_5g));
+ }
+ if (caps.supports4g) {
+ validModes.add("4G_ONLY"); validDisplays.add(activity.getString(R.string.text_4g_only));
+ validModes.add("PREF_4G"); validDisplays.add(activity.getString(R.string.pref_4g));
+ }
+ if (caps.supports3g) {
+ validModes.add("PREF_3G"); validDisplays.add(activity.getString(R.string.pref_3g));
+ }
+ if (caps.supports2g) {
+ validModes.add("2G_ONLY"); validDisplays.add(activity.getString(R.string.text_2g_only));
+ }
+
+ if (validModes.isEmpty()) {
+ validModes.add("PREF_4G");
+ validDisplays.add(activity.getString(R.string.pref_4g));
+ }
+
+ String currentSelectedMode = preferredMode;
+ if (currentSelectedMode == null) {
+ @SuppressWarnings("unchecked")
+ List oldValidModes = (List) row.getTag(R.id.spinnerMode);
+ if (oldValidModes != null && !oldValidModes.isEmpty()) {
+ int oldModePos = spinnerMode.getSelectedItemPosition();
+ if (oldModePos >= 0 && oldModePos < oldValidModes.size()) {
+ currentSelectedMode = oldValidModes.get(oldModePos);
+ }
+ }
+ }
+
+ row.setTag(R.id.spinnerMode, validModes);
+
+ ArrayAdapter modeAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, validDisplays);
+ spinnerMode.setAdapter(modeAdapter);
+
+ int targetPos = -1;
+ if (currentSelectedMode != null) {
+ targetPos = validModes.indexOf(currentSelectedMode);
+ }
+ if (targetPos < 0) {
+ targetPos = 0;
+ }
+ spinnerMode.setSelection(targetPos);
+ spinnerMode.setTag(R.id.spinnerMode, targetPos);
+ }
+
+ public void refreshCapabilities() {
+ activity.runOnUiThread(() -> {
+ rebuildSimLists();
+ for (View row : rows) {
+ Spinner spinnerSim = row.findViewById(R.id.spinnerSim);
+ if (spinnerSim != null) {
+ int currSimPos = spinnerSim.getSelectedItemPosition();
+ int currSimVal = (currSimPos >= 0 && currSimPos < simValueList.size()) ? simValueList.get(currSimPos) : 1;
+ ArrayAdapter simAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, simDisplayList);
+ spinnerSim.setAdapter(simAdapter);
+ int newSimPos = simValueList.indexOf(currSimVal);
+ if (newSimPos < 0) newSimPos = 0;
+ spinnerSim.setSelection(newSimPos);
+ spinnerSim.setTag(R.id.spinnerSim, newSimPos);
+ }
+ updateModesForRow(row, null);
+ }
+ if (syncShortcutsRunnable != null && !isRestoringState) {
+ syncShortcutsRunnable.run();
+ }
+ });
+ }
+
+ public void setup() {
+ ViewGroup container = activity.findViewById(R.id.shortcutsContainer);
+ View btnAdd = activity.findViewById(R.id.btnAddShortcut);
+ TextView badgeCount = activity.findViewById(R.id.badgeShortcutCount);
+ View bannerLimitReached = activity.findViewById(R.id.bannerLimitReached);
+
+ if (container == null || btnAdd == null) return;
+
+ LayoutInflater inflater = LayoutInflater.from(activity);
+ rows.clear();
+ container.removeAllViews();
+
+ rebuildSimLists();
+
+ // Auto-save runnable that syncs preferences and system ShortcutManager
+ Runnable syncShortcuts = () -> {
+ if (pendingTitleSync != null) {
+ titleDebounceHandler.removeCallbacks(pendingTitleSync);
+ pendingTitleSync = null;
+ }
+
+ if (isRestoringState) return;
+
+ int count = rows.size();
+ if (badgeCount != null) {
+ badgeCount.setText(activity.getString(R.string.routine_shortcuts_count_format, count));
+ }
+
+ if (bannerLimitReached != null) {
+ bannerLimitReached.setVisibility(count >= 4 ? View.VISIBLE : View.GONE);
+ }
+ updateFabVisibility();
+
+ StringBuilder sigBuilder = new StringBuilder();
+ for (int i = 0; i < count; i++) {
+ View row = rows.get(i);
+ EditText editTitle = row.findViewById(R.id.editShortcutTitle);
+ Spinner spinnerSim = row.findViewById(R.id.spinnerSim);
+ Spinner spinnerMode = row.findViewById(R.id.spinnerMode);
+
+ String rawName = editTitle != null ? editTitle.getText().toString().trim() : "";
+ int simPos = spinnerSim.getSelectedItemPosition();
+ int simValue = (simPos >= 0 && simPos < simValueList.size()) ? simValueList.get(simPos) : 1;
+
+ @SuppressWarnings("unchecked")
+ List validModes = (List) row.getTag(R.id.spinnerMode);
+ String selectedMode = "NONE";
+ if (validModes != null && !validModes.isEmpty()) {
+ int modePos = spinnerMode.getSelectedItemPosition();
+ if (modePos < 0 || modePos >= validModes.size()) modePos = 0;
+ selectedMode = validModes.get(modePos);
+ }
+ sigBuilder.append(i).append(':').append(rawName).append(':').append(selectedMode).append(':').append(simValue).append(';');
+ }
+ String currentSignature = sigBuilder.toString();
+ if (currentSignature.equals(lastSyncedSignature)) {
+ return;
+ }
+
+ List dynamicShortcuts = new ArrayList<>();
+
+ for (int i = 0; i < count; i++) {
+ int slot = i + 1;
+ View row = rows.get(i);
+ EditText editTitle = row.findViewById(R.id.editShortcutTitle);
+ Spinner spinnerSim = row.findViewById(R.id.spinnerSim);
+ Spinner spinnerMode = row.findViewById(R.id.spinnerMode);
+
+ String rawName = editTitle != null ? editTitle.getText().toString().trim() : "";
+ String shortcutLabel = rawName.isEmpty() ? ("Routine #" + slot) : rawName;
+
+ int simPos = spinnerSim.getSelectedItemPosition();
+ int simValue = (simPos >= 0 && simPos < simValueList.size()) ? simValueList.get(simPos) : 1;
+
+ @SuppressWarnings("unchecked")
+ List validModes = (List) row.getTag(R.id.spinnerMode);
+ String selectedMode = "NONE";
+ if (validModes != null && !validModes.isEmpty()) {
+ int modePos = spinnerMode.getSelectedItemPosition();
+ if (modePos < 0 || modePos >= validModes.size()) modePos = 0;
+ selectedMode = validModes.get(modePos);
+ }
+
+ prefs.saveRoutineShortcut(slot, rawName, selectedMode, simValue);
+
+ if (!"NONE".equals(selectedMode) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ Intent intent = new Intent(activity, ShortcutActionActivity.class);
+ intent.setAction("com.dhangofa.networktoggle.SHORTCUT_ACTION_" + slot);
+ intent.putExtra("mode", selectedMode);
+ intent.putExtra("sim", simValue);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+
+ String simLabel = (simPos >= 0 && simPos < simDisplayList.size()) ? simDisplayList.get(simPos) : "SIM";
+ String modeDisplay = spinnerMode.getSelectedItem() != null ? spinnerMode.getSelectedItem().toString() : selectedMode;
+
+ ShortcutInfo shortcut = new ShortcutInfo.Builder(activity, "routine_slot_" + slot)
+ .setShortLabel(shortcutLabel)
+ .setLongLabel(shortcutLabel + " (" + modeDisplay + ", " + simLabel + ")")
+ .setIcon(Icon.createWithResource(activity, R.drawable.ic_magic_wand))
+ .setIntent(intent)
+ .build();
+
+ dynamicShortcuts.add(shortcut);
+ }
+ }
+
+ for (int i = count + 1; i <= 4; i++) {
+ prefs.saveRoutineShortcut(i, "", "NONE", 1);
+ }
+
+ boolean shortcutUpdateSucceeded = true;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ try {
+ ShortcutManager sm = activity.getSystemService(ShortcutManager.class);
+ if (sm != null) {
+ sm.setDynamicShortcuts(dynamicShortcuts);
+ } else {
+ shortcutUpdateSucceeded = false;
+ Log.e("ShortcutTabHelper", "ShortcutManager service is unavailable");
+ }
+ } catch (Exception e) {
+ shortcutUpdateSucceeded = false;
+ Log.e("ShortcutTabHelper", "Failed to update dynamic shortcuts", e);
+ }
+ }
+ if (shortcutUpdateSucceeded) {
+ lastSyncedSignature = currentSignature;
+ }
+ };
+ this.syncShortcutsRunnable = syncShortcuts;
+
+ Runnable updateIndicesAndStyles = () -> {
+ for (int i = 0; i < rows.size(); i++) {
+ View row = rows.get(i);
+ TextView label = row.findViewById(R.id.slotLabel);
+ EditText editTitle = row.findViewById(R.id.editShortcutTitle);
+
+ int slotNum = i + 1;
+ if (label != null) {
+ label.setText(activity.getString(R.string.shortcut_hash_format, slotNum));
+ if (slotNum % 2 == 1) {
+ label.setBackgroundResource(R.drawable.shape_badge_slot_purple);
+ label.setTextColor(activity.getColor(R.color.shortcut_badge_purple_text));
+ } else {
+ label.setBackgroundResource(R.drawable.shape_badge_slot_green);
+ label.setTextColor(activity.getColor(R.color.shortcut_badge_green_text));
+ }
+ }
+
+ if (editTitle != null) {
+ editTitle.setHint("Routine #" + slotNum);
+ }
+ }
+ };
+
+ interface RowAdder { void add(String savedName, String pendingMode, int savedSimValue); }
+ RowAdder addRow = (savedName, pendingMode, savedSimValue) -> {
+ if (rows.size() >= 4) return;
+
+ View row = inflater.inflate(R.layout.item_routine_shortcut, container, false);
+ EditText editTitle = row.findViewById(R.id.editShortcutTitle);
+ Spinner spinnerSim = row.findViewById(R.id.spinnerSim);
+ Spinner spinnerMode = row.findViewById(R.id.spinnerMode);
+ View btnTestShortcut = row.findViewById(R.id.btnTestShortcut);
+ View btnPinShortcut = row.findViewById(R.id.btnPinShortcut);
+ ImageView btnRemove = row.findViewById(R.id.btnRemoveShortcut);
+
+ // Set Title
+ if (savedName != null && !savedName.isEmpty()) {
+ editTitle.setText(savedName);
+ } else {
+ editTitle.setText("");
+ }
+
+ // SIM Adapter
+ ArrayAdapter simAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, simDisplayList);
+ spinnerSim.setAdapter(simAdapter);
+
+ int initialSimPos = simValueList.indexOf(savedSimValue);
+ if (initialSimPos < 0) initialSimPos = 0;
+ spinnerSim.setSelection(initialSimPos);
+
+ // Immediately populate modes synchronously based on dynamic capabilities
+ updateModesForRow(row, pendingMode);
+ spinnerSim.setTag(R.id.spinnerSim, initialSimPos);
+ spinnerMode.setTag(R.id.spinnerMode, spinnerMode.getSelectedItemPosition());
+
+ spinnerSim.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (isRestoringState) return;
+ Object lastSim = spinnerSim.getTag(R.id.spinnerSim);
+ if (lastSim instanceof Integer && (Integer) lastSim == position) return;
+ spinnerSim.setTag(R.id.spinnerSim, position);
+ updateModesForRow(row, null);
+ syncShortcuts.run();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+
+ spinnerMode.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (isRestoringState) return;
+ Object lastMode = spinnerMode.getTag(R.id.spinnerMode);
+ if (lastMode instanceof Integer && (Integer) lastMode == position) return;
+ spinnerMode.setTag(R.id.spinnerMode, position);
+ syncShortcuts.run();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+
+ // Auto-save title on edit with debounce to prevent excessive disk and ShortcutManager operations
+ editTitle.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {}
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (isRestoringState) return;
+ scheduleDebouncedSync(syncShortcuts);
+ }
+ });
+
+ // Initial state for Test Shortcut button based on authorization
+ btnTestShortcut.setAlpha(isAuthorized ? 1.0f : 0.4f);
+
+ // Test Shortcut Action
+ btnTestShortcut.setOnClickListener(v -> {
+ if (!isAuthorized) {
+ Toast.makeText(activity, R.string.toast_auth_required_shortcut, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ int simPos = spinnerSim.getSelectedItemPosition();
+ int simValue = (simPos >= 0 && simPos < simValueList.size()) ? simValueList.get(simPos) : 1;
+
+ @SuppressWarnings("unchecked")
+ List validModes = (List) row.getTag(R.id.spinnerMode);
+ if (validModes == null || validModes.isEmpty()) return;
+
+ int modePos = spinnerMode.getSelectedItemPosition();
+ if (modePos < 0 || modePos >= validModes.size()) modePos = 0;
+ String selectedMode = validModes.get(modePos);
+
+ TargetSim targetSim = TargetSim.AUTO;
+ if (simValue == 1) targetSim = TargetSim.SIM_1;
+ else if (simValue == 2) targetSim = TargetSim.SIM_2;
+ else if (simValue == 3) targetSim = TargetSim.BOTH;
+
+ NetworkMode networkMode = NetworkMode.fromString(selectedMode);
+ if (networkMode != NetworkMode.UNKNOWN) {
+ final TargetSim finalTargetSim = targetSim;
+ AppExecutors.executeTelephony(() -> {
+ AutomationRequest request = new AutomationRequest(networkMode, finalTargetSim, false, "ShortcutTest");
+ AutomationExecutor.execute(activity.getApplicationContext(), request);
+ });
+ }
+
+ String displayMode = selectedMode.replace("_ONLY", " Only").replace("PREF_", "Pref ");
+ String simName = (simPos >= 0 && simPos < simDisplayList.size()) ? simDisplayList.get(simPos) : "SIM 1";
+ Toast.makeText(activity, activity.getString(R.string.toast_applying) + " " + displayMode + " (" + simName + ")...", Toast.LENGTH_SHORT).show();
+ });
+
+ // Pin to Home Screen Action
+ btnPinShortcut.setOnClickListener(v -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ShortcutManager shortcutManager = activity.getSystemService(ShortcutManager.class);
+ if (shortcutManager != null && shortcutManager.isRequestPinShortcutSupported()) {
+ int slot = rows.indexOf(row) + 1;
+ int simPos = spinnerSim.getSelectedItemPosition();
+ int simValue = (simPos >= 0 && simPos < simValueList.size()) ? simValueList.get(simPos) : 1;
+
+ @SuppressWarnings("unchecked")
+ List validModes = (List) row.getTag(R.id.spinnerMode);
+ String selectedMode = "5G_ONLY";
+ if (validModes != null && !validModes.isEmpty()) {
+ int modePos = spinnerMode.getSelectedItemPosition();
+ if (modePos < 0 || modePos >= validModes.size()) {
+ modePos = 0;
+ }
+ selectedMode = validModes.get(modePos);
+ }
+
+ String title = editTitle.getText().toString().trim();
+ if (title.isEmpty()) title = "Routine #" + slot;
+
+ Intent intent = new Intent(activity, ShortcutActionActivity.class);
+ intent.setAction("com.dhangofa.networktoggle.SHORTCUT_ACTION_" + slot);
+ intent.putExtra("mode", selectedMode);
+ intent.putExtra("sim", simValue);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+
+ String pinnedId = "pinned_" + selectedMode.toLowerCase(Locale.ROOT) + "_sim" + simValue + "_" + UUID.randomUUID().toString().substring(0, 8);
+ ShortcutInfo pinShortcut = new ShortcutInfo.Builder(activity, pinnedId)
+ .setShortLabel(title)
+ .setIcon(Icon.createWithResource(activity, R.drawable.ic_magic_wand))
+ .setIntent(intent)
+ .build();
+
+ shortcutManager.requestPinShortcut(pinShortcut, null);
+ Toast.makeText(activity, activity.getString(R.string.toast_shortcut_pinned), Toast.LENGTH_SHORT).show();
+ } else {
+ Toast.makeText(activity, activity.getString(R.string.toast_pin_not_supported), Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ Toast.makeText(activity, activity.getString(R.string.toast_pin_not_supported), Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ // Remove Button
+ btnRemove.setOnClickListener(v -> {
+ container.removeView(row);
+ rows.remove(row);
+ updateIndicesAndStyles.run();
+ syncShortcuts.run();
+ });
+
+ if (container instanceof GridLayout) {
+ GridLayout.LayoutParams glp = new GridLayout.LayoutParams();
+ glp.width = 0;
+ glp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ glp.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f);
+ glp.rowSpec = GridLayout.spec(GridLayout.UNDEFINED);
+ int margin = (int) (6 * activity.getResources().getDisplayMetrics().density);
+ glp.setMargins(margin, margin, margin, margin);
+ row.setLayoutParams(glp);
+ }
+
+ rows.add(row);
+ container.addView(row);
+ updateIndicesAndStyles.run();
+ };
+
+ btnAdd.setOnClickListener(v -> {
+ addRow.add("", null, 1);
+ syncShortcuts.run();
+ });
+
+ // Load existing saved shortcuts
+ isRestoringState = true;
+ int loadedCount = 0;
+ for (int i = 1; i <= 4; i++) {
+ String savedMode = prefs.getRoutineShortcutMode(i);
+ if (!"NONE".equals(savedMode)) {
+ String savedName = prefs.getRoutineShortcutName(i);
+ if (savedName == null || savedName.trim().isEmpty()) {
+ if (i == 1) savedName = "Home Ultra 5G";
+ else if (i == 2) savedName = "Office Battery Saver";
+ }
+ int savedSim = prefs.getRoutineShortcutSim(i);
+ addRow.add(savedName, savedMode, savedSim);
+ loadedCount++;
+ }
+ }
+
+ // If newly installed and empty, pre-populate 2 helpful default shortcuts on very first app run
+ if (loadedCount == 0 && !prefs.isRoutineShortcutsInitialized()) {
+ addRow.add("Home Ultra 5G", "5G_ONLY", 1);
+ addRow.add("Office Battery Saver", "4G_ONLY", 1);
+ prefs.setRoutineShortcutsInitialized(true);
+ } else {
+ prefs.setRoutineShortcutsInitialized(true);
+ }
+
+ isRestoringState = false;
+ updateIndicesAndStyles.run();
+ syncShortcuts.run();
+ }
+
+ public void setKeyboardOpen(boolean open) {
+ this.isKeyboardOpen = open;
+ this.updateFabVisibility();
+ }
+
+ public boolean isShortcutTabActive() {
+ boolean isAutoTabVisible;
+ if (activity instanceof MainActivity) {
+ isAutoTabVisible = ((MainActivity) activity).getCurrentTabIndex() == 1;
+ } else {
+ View tabAutomation = activity.findViewById(R.id.tabAutomation);
+ isAutoTabVisible = tabAutomation != null && tabAutomation.getVisibility() == View.VISIBLE;
+ }
+ View pageShortcuts = activity.findViewById(R.id.pageShortcuts);
+ return isAutoTabVisible && pageShortcuts != null && pageShortcuts.getVisibility() == View.VISIBLE;
+ }
+
+ public void updateFabVisibility() {
+ View btnAdd = activity.findViewById(R.id.btnAddShortcut);
+ if (btnAdd == null) return;
+ View bottomNavPill = activity.findViewById(R.id.bottomNavPill);
+ boolean isLandscape = activity.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+ boolean navReady = isLandscape || (bottomNavPill != null && bottomNavPill.getVisibility() == View.VISIBLE);
+
+ if (!isKeyboardOpen && navReady && rows.size() < 4 && isShortcutTabActive()) {
+ btnAdd.setVisibility(View.VISIBLE);
+ btnAdd.setAlpha(1.0f);
+ btnAdd.setScaleX(1.0f);
+ btnAdd.setScaleY(1.0f);
+ } else {
+ btnAdd.setVisibility(View.GONE);
+ }
+ }
+
+ public int getShortcutCount() {
+ return rows.size();
+ }
+
+ public void destroy() {
+ titleDebounceHandler.removeCallbacksAndMessages(null);
+ pendingTitleSync = null;
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/TargetSimUiController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/TargetSimUiController.java
new file mode 100644
index 00000000..b95e17a6
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/TargetSimUiController.java
@@ -0,0 +1,272 @@
+package com.dhangofa.networktoggle.ui;
+
+/** Handles Target SIM selection, validation, separators, and the Auto SIM warning. */
+import android.Manifest;
+import android.app.Activity;
+import android.content.pm.PackageManager;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.OvershootInterpolator;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.dhangofa.networktoggle.R;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.TargetSim;
+
+import java.util.List;
+
+public final class TargetSimUiController {
+ private final Activity activity;
+ private final AppPreferences prefs;
+ private final Runnable changed;
+
+ private final RadioGroup group;
+ private final RadioButton auto;
+ private final RadioButton sim1;
+ private final RadioButton sim2;
+ private final RadioButton simBoth;
+ private final TextView warning;
+ private final View sep1;
+ private final View sep2;
+ private final View sep3;
+ private final View lockBadge;
+
+ private boolean updating;
+ private Boolean authorized = null;
+
+ public TargetSimUiController(Activity activity, AppPreferences prefs, Runnable changed) {
+ this.activity = activity;
+ this.prefs = prefs;
+ this.changed = changed;
+
+ this.group = activity.findViewById(R.id.targetSimRadioGroup);
+ this.auto = activity.findViewById(R.id.radioSimAuto);
+ this.sim1 = activity.findViewById(R.id.radioSim1);
+ this.sim2 = activity.findViewById(R.id.radioSim2);
+ this.simBoth = activity.findViewById(R.id.radioSimBoth);
+ this.warning = activity.findViewById(R.id.autoSimWarningText);
+ this.lockBadge = activity.findViewById(R.id.targetSimLockBadge);
+ this.sep1 = activity.findViewById(R.id.separatorAutoSim1);
+ this.sep2 = activity.findViewById(R.id.separatorSim1Sim2);
+ this.sep3 = activity.findViewById(R.id.separatorSim2Both);
+ }
+
+ public void initialize() {
+ TargetSim targetSim = prefs.getTargetSim();
+
+ if (targetSim == TargetSim.SIM_1) {
+ sim1.setChecked(true);
+ } else if (targetSim == TargetSim.SIM_2) {
+ sim2.setChecked(true);
+ } else if (targetSim == TargetSim.BOTH) {
+ simBoth.setChecked(true);
+ } else {
+ auto.setChecked(true);
+ }
+
+ View.OnTouchListener lock = (v, e) -> {
+ boolean isAuth = authorized != null && authorized;
+ if (!isAuth && e.getAction() == MotionEvent.ACTION_DOWN) {
+ Toast.makeText(activity, activity.getString(R.string.toast_auth_required_sim), Toast.LENGTH_SHORT).show();
+ return true;
+ }
+ return false;
+ };
+
+ auto.setOnTouchListener(lock);
+ sim1.setOnTouchListener(lock);
+ sim2.setOnTouchListener(lock);
+ simBoth.setOnTouchListener(lock);
+
+ group.setOnCheckedChangeListener((g, id) -> select(id));
+ updateSeparators();
+ updateAutoSimWarning();
+ }
+
+ private void select(int id) {
+ if (updating) return;
+
+ TargetSim targetSim = TargetSim.AUTO;
+ if (id == R.id.radioSim1) {
+ targetSim = TargetSim.SIM_1;
+ } else if (id == R.id.radioSim2) {
+ targetSim = TargetSim.SIM_2;
+ } else if (id == R.id.radioSimBoth) {
+ targetSim = TargetSim.BOTH;
+ }
+
+ if (targetSim != TargetSim.AUTO && !exists(targetSim)) {
+ if (targetSim == TargetSim.BOTH) {
+ Toast.makeText(activity, activity.getString(R.string.toast_both_sims_required), Toast.LENGTH_SHORT).show();
+ } else {
+ Toast.makeText(activity, activity.getString(targetSim.getManualSlotIndex() == 0 ? R.string.toast_no_sim_slot_1 : R.string.toast_no_sim_slot_2), Toast.LENGTH_SHORT).show();
+ }
+ updating = true;
+ auto.setChecked(true);
+ updating = false;
+ targetSim = TargetSim.AUTO;
+ }
+
+ updateSeparators();
+ prefs.onTargetSimChanged(targetSim);
+ updateAutoSimWarning();
+
+ if (changed != null) {
+ changed.run();
+ }
+ }
+
+ private boolean exists(TargetSim target) {
+ if (activity.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
+ return true;
+ }
+
+ SubscriptionManager sm = activity.getSystemService(SubscriptionManager.class);
+ if (sm == null) {
+ return true;
+ }
+
+ try {
+ List infos = sm.getActiveSubscriptionInfoList();
+ if (infos == null) return false;
+
+ if (target == TargetSim.BOTH) {
+ boolean hasSim1 = false;
+ boolean hasSim2 = false;
+ for (SubscriptionInfo info : infos) {
+ if (info.getSimSlotIndex() == 0) hasSim1 = true;
+ if (info.getSimSlotIndex() == 1) hasSim2 = true;
+ }
+ return hasSim1 && hasSim2;
+ }
+
+ for (SubscriptionInfo info : infos) {
+ if (info.getSimSlotIndex() == target.getManualSlotIndex()) {
+ return true;
+ }
+ }
+ } catch (Exception ignored) {
+ return true;
+ }
+ return false;
+ }
+
+ public void setAuthorized(boolean value) {
+ if (this.authorized != null && this.authorized == value) return;
+ boolean animate = (this.authorized != null);
+ this.authorized = value;
+ float alpha = value ? 1.0f : 0.75f;
+ View card = activity.findViewById(R.id.cardTargetSim);
+ if (card != null) {
+ if (animate) {
+ if (value) {
+ float offset = 6f * activity.getResources().getDisplayMetrics().density;
+ card.setTranslationY(offset);
+ card.animate()
+ .alpha(alpha)
+ .translationY(0f)
+ .setDuration(320)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ } else {
+ card.animate()
+ .alpha(alpha)
+ .translationY(0f)
+ .setDuration(320)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ } else {
+ card.setAlpha(alpha);
+ card.setTranslationY(0f);
+ }
+ }
+ if (this.lockBadge != null) {
+ if (value) {
+ if (animate && lockBadge.getVisibility() == View.VISIBLE) {
+ this.lockBadge.animate()
+ .alpha(0f)
+ .scaleX(0.4f)
+ .scaleY(0.4f)
+ .setDuration(220)
+ .withEndAction(() -> {
+ this.lockBadge.setVisibility(View.GONE);
+ this.lockBadge.setScaleX(1f);
+ this.lockBadge.setScaleY(1f);
+ this.lockBadge.setAlpha(1f);
+ })
+ .start();
+ } else {
+ this.lockBadge.setVisibility(View.GONE);
+ }
+ } else {
+ if (animate && lockBadge.getVisibility() != View.VISIBLE) {
+ this.lockBadge.setVisibility(View.VISIBLE);
+ this.lockBadge.setAlpha(0f);
+ this.lockBadge.setScaleX(0.4f);
+ this.lockBadge.setScaleY(0.4f);
+ this.lockBadge.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(250)
+ .setInterpolator(new OvershootInterpolator(1.2f))
+ .start();
+ } else {
+ this.lockBadge.setVisibility(View.VISIBLE);
+ this.lockBadge.setAlpha(1f);
+ this.lockBadge.setScaleX(1f);
+ this.lockBadge.setScaleY(1f);
+ }
+ }
+ }
+ }
+
+ public void updateAutoSimWarning() {
+ if (warning == null) return;
+
+ boolean show = prefs.hasAutoSimError() && prefs.getTargetSim() == TargetSim.AUTO;
+ warning.setVisibility(show ? View.VISIBLE : View.GONE);
+
+ if (show) {
+ warning.setText(R.string.warning_auto_sim_failed);
+ warning.setTextColor(activity.getColor(R.color.status_error_text));
+ }
+ }
+
+ private void updateSeparators() {
+ int id = group.getCheckedRadioButtonId();
+
+ if (id == -1) {
+ sep1.setVisibility(View.VISIBLE);
+ sep2.setVisibility(View.VISIBLE);
+ sep3.setVisibility(View.VISIBLE);
+ } else if (id == R.id.radioSimAuto) {
+ sep1.setVisibility(View.INVISIBLE);
+ sep2.setVisibility(View.VISIBLE);
+ sep3.setVisibility(View.VISIBLE);
+ } else if (id == R.id.radioSim1) {
+ sep1.setVisibility(View.INVISIBLE);
+ sep2.setVisibility(View.INVISIBLE);
+ sep3.setVisibility(View.VISIBLE);
+ } else if (id == R.id.radioSim2) {
+ sep1.setVisibility(View.VISIBLE);
+ sep2.setVisibility(View.INVISIBLE);
+ sep3.setVisibility(View.INVISIBLE);
+ } else if (id == R.id.radioSimBoth) {
+ sep1.setVisibility(View.VISIBLE);
+ sep2.setVisibility(View.VISIBLE);
+ sep3.setVisibility(View.INVISIBLE);
+ } else {
+ sep1.setVisibility(View.VISIBLE);
+ sep2.setVisibility(View.VISIBLE);
+ sep3.setVisibility(View.VISIBLE);
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/ThemeController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/ThemeController.java
new file mode 100644
index 00000000..dbe33831
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/ThemeController.java
@@ -0,0 +1,134 @@
+package com.dhangofa.networktoggle.ui;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+import android.content.res.ColorStateList;
+import android.graphics.drawable.GradientDrawable;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.Window;
+import android.widget.ImageView;
+
+import com.dhangofa.networktoggle.R;
+
+public class ThemeController {
+ private final Activity activity;
+ private final SharedPreferences prefs;
+ private int currentThemeMode = -1;
+ private ImageView btnThemeToggle;
+ private View navIndicatorPill;
+
+ public ThemeController(Activity activity, SharedPreferences prefs) {
+ this.activity = activity;
+ this.prefs = prefs;
+ this.currentThemeMode = prefs.getInt("app_theme", 1);
+ activity.setTheme(R.style.Theme_NetToggle);
+ }
+
+ public int getCurrentThemeMode() {
+ return currentThemeMode;
+ }
+
+ public boolean isAmoled() {
+ return currentThemeMode == 3;
+ }
+
+ public void bindViews(ImageView btnThemeToggle, View navIndicatorPill) {
+ this.btnThemeToggle = btnThemeToggle;
+ this.navIndicatorPill = navIndicatorPill;
+ if (this.btnThemeToggle != null) {
+ this.btnThemeToggle.setOnClickListener(v -> cycleThemeMode());
+ }
+ updateThemeIcon();
+ applyThemeOverrides();
+ }
+
+ private void cycleThemeMode() {
+ currentThemeMode = (currentThemeMode + 1) % 4;
+ prefs.edit().putInt("app_theme", currentThemeMode).apply();
+ activity.recreate();
+ }
+
+ private void updateThemeIcon() {
+ if (btnThemeToggle == null) return;
+ if (currentThemeMode == 0) {
+ btnThemeToggle.setImageResource(R.drawable.ic_theme_auto);
+ } else if (currentThemeMode == 1) {
+ btnThemeToggle.setImageResource(R.drawable.ic_theme_light);
+ } else if (currentThemeMode == 2) {
+ btnThemeToggle.setImageResource(R.drawable.ic_theme_dark);
+ } else {
+ btnThemeToggle.setImageResource(R.drawable.ic_theme_amoled);
+ }
+ }
+
+
+ public void configureStatusBar() {
+ int flags;
+ if (android.os.Build.VERSION.SDK_INT < 23) {
+ return;
+ }
+ boolean isNight = (activity.getResources().getConfiguration().uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES;
+ Window window = activity.getWindow();
+ if (this.currentThemeMode == 3) {
+ window.setStatusBarColor(-16777216);
+ window.setNavigationBarColor(-16777216);
+ } else {
+ window.setStatusBarColor(activity.getColor(R.color.surface_background));
+ window.setNavigationBarColor(activity.getColor(R.color.surface_background));
+ }
+ int n = flags = isNight ? 0 : 8192; // SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
+ if (!isNight && android.os.Build.VERSION.SDK_INT >= 26) {
+ flags |= 16; // SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
+ }
+ window.getDecorView().setSystemUiVisibility(flags);
+ }
+
+ public void applyThemeOverrides() {
+ View btnAdd = activity.findViewById(R.id.btnAddShortcut);
+ if (btnAdd != null) {
+ btnAdd.setBackgroundResource(currentThemeMode == 3 ? R.drawable.shape_fab_wavy_amoled : R.drawable.shape_fab_wavy);
+ btnAdd.setOutlineProvider(new android.view.ViewOutlineProvider() {
+ @Override
+ public void getOutline(android.view.View view, android.graphics.Outline outline) {
+ int insetX = view.getWidth() / 8;
+ int insetY = view.getHeight() / 8;
+ outline.setOval(insetX, insetY, view.getWidth() - insetX, view.getHeight() - insetY);
+ }
+ });
+ }
+ ImageView iconAdd = activity.findViewById(R.id.iconAddShortcut);
+ if (iconAdd != null && currentThemeMode == 3) {
+ iconAdd.setImageTintList(ColorStateList.valueOf(activity.getColor(R.color.fab_add_shortcut_icon_amoled)));
+ }
+ if (currentThemeMode == 3) {
+ int black = -16777216;
+ View root = activity.findViewById(R.id.mainRoot);
+ if (root != null) root.setBackgroundColor(black);
+
+ Window window = activity.getWindow();
+ if (window != null) {
+ window.setStatusBarColor(black);
+ window.setNavigationBarColor(black);
+ }
+
+ View bottomNavPill = activity.findViewById(R.id.bottomNavPill);
+ if (bottomNavPill != null && bottomNavPill.getBackground() instanceof GradientDrawable) {
+ GradientDrawable gd = (GradientDrawable) bottomNavPill.getBackground().mutate();
+ gd.setColor(black);
+ gd.setStroke((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.0f, activity.getResources().getDisplayMetrics()), activity.getColor(R.color.nav_pill_stroke_amoled));
+ }
+ View edgeBarContainer = activity.findViewById(R.id.edgeBarContainer);
+ if (edgeBarContainer != null && edgeBarContainer.getBackground() instanceof GradientDrawable) {
+ GradientDrawable gd = (GradientDrawable) edgeBarContainer.getBackground().mutate();
+ gd.setColor(black);
+ gd.setStroke((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.0f, activity.getResources().getDisplayMetrics()), activity.getColor(R.color.nav_pill_stroke_amoled));
+ }
+
+ if (navIndicatorPill != null && navIndicatorPill.getBackground() instanceof GradientDrawable) {
+ ((GradientDrawable) navIndicatorPill.getBackground().mutate())
+ .setColor(activity.getColor(R.color.nav_item_selected_bg_amoled));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleSyncController.java b/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleSyncController.java
new file mode 100644
index 00000000..52ddef91
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleSyncController.java
@@ -0,0 +1,135 @@
+package com.dhangofa.networktoggle.ui;
+
+/** Synchronizes the active modem mode when the Quick Tile Cycle changes. */
+import android.content.ComponentName;
+import android.content.Context;
+import android.service.quicksettings.TileService;
+import com.dhangofa.networktoggle.NetworkTileService;
+import com.dhangofa.networktoggle.config.AppPreferences;
+import com.dhangofa.networktoggle.model.CommandResult;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.NetworkMode;
+import com.dhangofa.networktoggle.telephony.NetworkModeController;
+import com.dhangofa.networktoggle.telephony.NetworkModeReader;
+import com.dhangofa.networktoggle.telephony.SimResolver;
+import com.dhangofa.networktoggle.util.AppExecutors;
+import java.util.List;
+
+public final class TileCycleSyncController implements TileCycleUiController.OnCycleChangedListener {
+
+ private final Context context;
+ private final AppPreferences prefs;
+ private final SimResolver simResolver;
+ private final NetworkModeController controller;
+
+ public TileCycleSyncController(Context context, AppPreferences prefs, SimResolver simResolver, NetworkModeController controller) {
+ this.context = context.getApplicationContext();
+ this.prefs = prefs;
+ this.simResolver = simResolver;
+ this.controller = controller;
+ }
+
+ @Override
+ public void onCycleChanged(List newCycle) {
+ AppExecutors.executeTelephony(() -> sync(newCycle));
+ }
+
+ private void sync(List newCycle) {
+ NetworkMode currentMode = prefs.getCachedNetworkMode();
+
+ // If cache is wiped (e.g. from SIM switch), but execution is allowed, read it directly once
+ if (currentMode == NetworkMode.UNKNOWN && prefs.getExecutionMode() != ExecutionMode.NONE) {
+ currentMode = new NetworkModeReader(context, prefs, simResolver).readCurrentMode();
+ if (currentMode != NetworkMode.UNKNOWN) {
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ prefs.setCachedNetworkMode(currentMode);
+ }
+ }
+
+ if (currentMode != NetworkMode.UNKNOWN && !newCycle.contains(currentMode)) {
+ NetworkMode fallbackMode = newCycle.get(0);
+
+ if (prefs.getTargetSim() == com.dhangofa.networktoggle.model.TargetSim.BOTH) {
+ SimResolver.SimInfo info1 = simResolver.resolveTargetSimInfo(prefs.getExecutionMode(), com.dhangofa.networktoggle.model.TargetSim.SIM_1);
+ SimResolver.SimInfo info2 = simResolver.resolveTargetSimInfo(prefs.getExecutionMode(), com.dhangofa.networktoggle.model.TargetSim.SIM_2);
+ if (info1 == null || info2 == null) {
+ prefs.setLastError("", -1, "", "One or both SIMs are not available for sync.", "TileCycle Sync Both failed: SIM unavailable");
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ TileService.requestListeningState(context, new ComponentName(context, NetworkTileService.class));
+ return;
+ }
+
+ boolean success = false;
+ boolean isPartial = false;
+ CommandResult r1 = CommandResult.failed("", "SIM 1 was not attempted.");
+ CommandResult r2 = CommandResult.failed("", "SIM 2 was not attempted.");
+ try {
+ simResolver.setOverrideTargetSim(com.dhangofa.networktoggle.model.TargetSim.SIM_1);
+ CommandResult res1 = controller.apply(fallbackMode, prefs.getExecutionMode());
+ if (res1 != null) {
+ r1 = res1;
+ }
+
+ simResolver.setOverrideTargetSim(com.dhangofa.networktoggle.model.TargetSim.SIM_2);
+ CommandResult res2 = controller.apply(fallbackMode, prefs.getExecutionMode());
+ if (res2 != null) {
+ r2 = res2;
+ }
+
+ if (r1.isSuccess() && r2.isSuccess()) {
+ success = true;
+ } else if (r1.isSuccess() || r2.isSuccess()) {
+ isPartial = true;
+ }
+ } catch (Throwable t) {
+ if (!r1.isSuccess()) {
+ r1 = CommandResult.failed("", "Exception applying mode to SIM 1: " + t.getMessage());
+ } else {
+ r2 = CommandResult.failed("", "Exception applying mode to SIM 2: " + t.getMessage());
+ }
+ } finally {
+ simResolver.setOverrideTargetSim(null);
+ }
+
+ if (success) {
+ prefs.setCachedNetworkMode(fallbackMode);
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ } else if (isPartial) {
+ prefs.setCachedNetworkMode(NetworkMode.UNKNOWN);
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ CommandResult failed = r1.isSuccess() ? r2 : r1;
+ prefs.setLastError(failed.getCommand(), failed.getExitCode(), failed.getStdout(), failed.getStderr(), "TileCycle Sync partial failure");
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ } else {
+ prefs.setLastError(r1.getCommand(), r1.getExitCode(), r1.getStdout(), r1.getStderr(), "TileCycle Sync Both failed");
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ }
+ TileService.requestListeningState(context, new ComponentName(context, NetworkTileService.class));
+ } else {
+ CommandResult result = CommandResult.failed("", "Execution not attempted");
+ try {
+ CommandResult res = controller.apply(fallbackMode, prefs.getExecutionMode());
+ if (res != null) {
+ result = res;
+ }
+ } catch (Throwable t) {
+ result = CommandResult.failed("", "Exception applying mode: " + t.getMessage());
+ }
+
+ if (result.isSuccess()) {
+ prefs.setCachedNetworkMode(fallbackMode);
+ prefs.setLastNetworkCheckTimestamp(System.currentTimeMillis());
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_NONE);
+ } else {
+ prefs.setLastError(result.getCommand(), result.getExitCode(), result.getStdout(), result.getStderr(), "TileCycle Sync failed");
+ prefs.setTileErrorState(AppPreferences.TILE_ERROR_CMD);
+ }
+ TileService.requestListeningState(context, new ComponentName(context, NetworkTileService.class));
+ }
+ } else if (currentMode != NetworkMode.UNKNOWN) {
+ // It's in the cycle, ensure it is cached so UI shows the actual active state
+ prefs.setCachedNetworkMode(currentMode);
+ }
+ }
+}
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 f16c70bd..446ab1fe 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/TileCycleUiController.java
@@ -1,8 +1,17 @@
package com.dhangofa.networktoggle.ui;
+/**
+ * UI controller to render and manage the network cycle list in the app, preserving selection order.
+ * It handles adding, removing, and toggling the network modes that the QS tile will cycle through.
+ */
+
import android.app.Activity;
+import android.view.MotionEvent;
import android.view.View;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.OvershootInterpolator;
import android.widget.CheckBox;
+import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
@@ -16,49 +25,57 @@
public final class TileCycleUiController {
private final Activity activity;
private final TileCycleManager cycleManager;
-
+ private final AppPreferences appPreferences;
+
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 final View lockBadge;
+ private final Switch switchAutoRestore;
+ private final View rowAutoRestoreToggle;
private boolean updatingUi;
private AppPreferences.NetworkCapabilities currentCaps;
private OnCycleChangedListener cycleChangedListener;
- private boolean isAuthorized = true;
+ private Boolean isAuthorized = null;
public interface OnCycleChangedListener {
void onCycleChanged(List newCycle);
}
- public TileCycleUiController(Activity activity, TileCycleManager cycleManager) {
+ public TileCycleUiController(Activity activity, TileCycleManager cycleManager, AppPreferences appPreferences) {
this.activity = activity;
this.cycleManager = cycleManager;
-
+ this.appPreferences = appPreferences;
+
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);
+ lockBadge = activity.findViewById(R.id.tileCycleLockBadge);
+ switchAutoRestore = activity.findViewById(R.id.switchAutoRestore);
+ rowAutoRestoreToggle = activity.findViewById(R.id.rowAutoRestoreToggle);
}
public void setOnCycleChangedListener(OnCycleChangedListener listener) {
@@ -67,10 +84,11 @@ public void setOnCycleChangedListener(OnCycleChangedListener listener) {
public void initialize() {
refresh();
-
+
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.");
+ boolean isAuth = isAuthorized != null && isAuthorized;
+ if (!isAuth && event.getAction() == android.view.MotionEvent.ACTION_DOWN) {
+ showToast(activity.getString(R.string.toast_auth_required_tile));
return true; // Consume event to prevent visual change
}
return false;
@@ -94,30 +112,152 @@ public void initialize() {
handleSelection(NetworkMode.FOUR_G_ONLY, selected));
mode2gOnly.setOnCheckedChangeListener((button, selected) ->
handleSelection(NetworkMode.TWO_G_ONLY, selected));
+
+ if (switchAutoRestore != null) {
+ switchAutoRestore.setOnTouchListener((v, event) -> {
+ boolean isAuth = isAuthorized != null && isAuthorized;
+ if (!isAuth) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ showToast(activity.getString(R.string.toast_auth_required_tile));
+ }
+ return true;
+ }
+ return false;
+ });
+
+ switchAutoRestore.setOnClickListener(v -> {
+ boolean isAuth = isAuthorized != null && isAuthorized;
+ if (!isAuth) {
+ switchAutoRestore.setChecked(false);
+ showToast(activity.getString(R.string.toast_auth_required_tile));
+ }
+ });
+
+ switchAutoRestore.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (updatingUi) return;
+ boolean isAuth = isAuthorized != null && isAuthorized;
+ if (!isAuth && isChecked) {
+ switchAutoRestore.setChecked(false);
+ showToast(activity.getString(R.string.toast_auth_required_tile));
+ return;
+ }
+ if (appPreferences != null) {
+ appPreferences.setAutoRestorePreferredModeEnabled(isChecked);
+ }
+ });
+ }
+
+ if (rowAutoRestoreToggle != null) {
+ rowAutoRestoreToggle.setOnClickListener(v -> {
+ boolean isAuth = isAuthorized != null && isAuthorized;
+ if (!isAuth) {
+ showToast(activity.getString(R.string.toast_auth_required_tile));
+ return;
+ }
+ if (switchAutoRestore != null) {
+ switchAutoRestore.toggle();
+ }
+ });
+ }
}
public void setAuthorized(boolean authorized) {
- if (this.isAuthorized == authorized) return;
+ if (this.isAuthorized != null && this.isAuthorized == authorized) return;
+ boolean animate = (this.isAuthorized != null);
this.isAuthorized = authorized;
- float alpha = authorized ? 1.0f : 0.4f;
-
+ float alpha = authorized ? 1.0f : 0.75f;
+
View card = activity.findViewById(R.id.cardTileCycle);
if (card != null) {
- card.setAlpha(alpha);
+ if (animate) {
+ if (authorized) {
+ float offset = 6f * activity.getResources().getDisplayMetrics().density;
+ card.setTranslationY(offset);
+ card.animate()
+ .alpha(alpha)
+ .translationY(0f)
+ .setDuration(320)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ } else {
+ card.animate()
+ .alpha(alpha)
+ .translationY(0f)
+ .setDuration(320)
+ .setInterpolator(new DecelerateInterpolator())
+ .start();
+ }
+ } else {
+ card.setAlpha(alpha);
+ card.setTranslationY(0f);
+ }
+ }
+ if (lockBadge != null) {
+ if (authorized) {
+ if (animate && lockBadge.getVisibility() == View.VISIBLE) {
+ lockBadge.animate()
+ .alpha(0f)
+ .scaleX(0.4f)
+ .scaleY(0.4f)
+ .setDuration(220)
+ .withEndAction(() -> {
+ lockBadge.setVisibility(View.GONE);
+ lockBadge.setScaleX(1f);
+ lockBadge.setScaleY(1f);
+ lockBadge.setAlpha(1f);
+ })
+ .start();
+ } else {
+ lockBadge.setVisibility(View.GONE);
+ }
+ } else {
+ if (animate && lockBadge.getVisibility() != View.VISIBLE) {
+ lockBadge.setVisibility(View.VISIBLE);
+ lockBadge.setAlpha(0f);
+ lockBadge.setScaleX(0.4f);
+ lockBadge.setScaleY(0.4f);
+ lockBadge.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(250)
+ .setInterpolator(new OvershootInterpolator(1.2f))
+ .start();
+ } else {
+ lockBadge.setVisibility(View.VISIBLE);
+ lockBadge.setAlpha(1f);
+ lockBadge.setScaleX(1f);
+ lockBadge.setScaleY(1f);
+ }
+ }
+ }
+
+ if (switchAutoRestore != null && appPreferences != null) {
+ updatingUi = true;
+ if (!authorized && appPreferences.isAutoRestorePreferredModeEnabled()) {
+ appPreferences.setAutoRestorePreferredModeEnabled(false);
+ }
+ switchAutoRestore.setChecked(authorized && appPreferences.isAutoRestorePreferredModeEnabled());
+ updatingUi = false;
+ }
+ }
+
+ private void animateAlpha(View view, float targetAlpha) {
+ if (view == null) return;
+ if (Math.abs(view.getAlpha() - targetAlpha) > 0.01f) {
+ view.animate().alpha(targetAlpha).setDuration(250).setInterpolator(new DecelerateInterpolator()).start();
}
}
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);
-
+
+ animateAlpha(modePref5g, caps.supports5g ? 1.0f : 0.4f);
+ animateAlpha(mode5gOnly, caps.supports5g ? 1.0f : 0.4f);
+ animateAlpha(modePref3g, caps.supports3g ? 1.0f : 0.4f);
+ animateAlpha(mode2gOnly, caps.supports2g ? 1.0f : 0.4f);
+
if (cycleManager.forceRemoveUnsupportedAndAutoFill(caps)) {
showToast("Cycle auto-adjusted for current SIM capabilities");
if (cycleChangedListener != null) {
@@ -142,15 +282,15 @@ private void handleSelection(NetworkMode mode, boolean selected) {
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.";
+ reason = activity.getString(R.string.tilecycle_5g_unsupported);
} else if (mode == NetworkMode.PREFERRED_3G && !currentCaps.supports3g) {
supported = false;
- reason = "3G is disabled or not supported by your current carrier.";
+ reason = activity.getString(R.string.tilecycle_3g_unsupported);
} else if (mode == NetworkMode.TWO_G_ONLY && !currentCaps.supports2g) {
supported = false;
- reason = "2G is disabled or not supported by your current carrier.";
+ reason = activity.getString(R.string.tilecycle_2g_unsupported);
}
-
+
if (!supported) {
showToast(reason);
refresh(); // Revert UI to match the actual saved cycle
@@ -164,9 +304,9 @@ private void handleSelection(NetworkMode mode, boolean selected) {
cycleChangedListener.onCycleChanged(cycleManager.getCycle());
}
} else if (result == TileCycleManager.ChangeResult.MINIMUM_REACHED) {
- showToast("Select at least 2 tile modes.");
+ showToast(activity.getString(R.string.toast_select_at_least_2));
} else if (result == TileCycleManager.ChangeResult.MAXIMUM_REACHED) {
- showToast("You can select up to 3 tile modes.");
+ showToast(activity.getString(R.string.toast_select_up_to_3));
}
refresh();
}
@@ -174,27 +314,33 @@ private void handleSelection(NetworkMode mode, boolean selected) {
private void refresh() {
updatingUi = true;
List cycle = cycleManager.getCycle();
-
+
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");
+
+ selectedCount.setText(activity.getString(R.string.cycle_selected_count, cycle.size()));
cycleOrder.setText(buildOrderText(cycle));
+
+ if (switchAutoRestore != null && appPreferences != null) {
+ boolean isChecked = (isAuthorized != null && isAuthorized) && appPreferences.isAutoRestorePreferredModeEnabled();
+ switchAutoRestore.setChecked(isChecked);
+ }
+
updatingUi = false;
}
private String buildOrderText(List cycle) {
- StringBuilder text = new StringBuilder("Cycle: ");
+ StringBuilder text = new StringBuilder(activity.getString(R.string.cycle_prefix));
for (int i = 0; i < cycle.size(); i++) {
if (i > 0) text.append(" → ");
text.append(cycle.get(i).getDisplayName());
diff --git a/app/src/main/java/com/dhangofa/networktoggle/ui/TileIconManager.java b/app/src/main/java/com/dhangofa/networktoggle/ui/TileIconManager.java
new file mode 100644
index 00000000..b84f483f
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/ui/TileIconManager.java
@@ -0,0 +1,83 @@
+package com.dhangofa.networktoggle.ui;
+
+/**
+ * Utility to dynamically draw the Quick Settings tile icons.
+ * Instead of having dozens of static image files, this draws the text (like "5G", "LTE")
+ * directly onto a blank icon canvas on the fly.
+ */
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Typeface;
+import android.graphics.drawable.Icon;
+import android.graphics.DashPathEffect;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class TileIconManager {
+ private static final Map iconCache = new ConcurrentHashMap<>();
+
+ private TileIconManager() {}
+
+ private static Icon createTextOnlyIcon(String text, String badge, boolean isAuto) {
+ int size = 256;
+ Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setColor(Color.WHITE);
+ paint.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD));
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setTextSize(175f);
+
+ float width = paint.measureText(text);
+ if (width > 240f) {
+ paint.setTextScaleX(240f / width);
+ }
+
+ Paint.FontMetrics metrics = paint.getFontMetrics();
+ float y = (size / 2f) - (metrics.descent + metrics.ascent) / 2f;
+ // Shift text slightly to the left and down to make room for the badge
+ canvas.drawText(text, (size / 2f) - 10f, y + 10f, paint);
+
+ if (badge != null && !badge.isEmpty()) {
+ float cx = size - 35f;
+ float cy = 35f;
+ float radius = 28f;
+
+ // Draw circle
+ Paint circlePaint = new Paint();
+ circlePaint.setAntiAlias(true);
+ circlePaint.setColor(Color.WHITE);
+ circlePaint.setStyle(Paint.Style.STROKE);
+ circlePaint.setStrokeWidth(9f);
+ if (isAuto) {
+ // Circumference is ~176 (2 * pi * 28). To get 4 dashes with smaller gaps, dash+gap should be ~44.
+ circlePaint.setPathEffect(new DashPathEffect(new float[]{36f, 8f}, 0f));
+ }
+ canvas.drawCircle(cx, cy, radius, circlePaint);
+
+ // Draw badge text
+ Paint badgeTextPaint = new Paint();
+ badgeTextPaint.setAntiAlias(true);
+ badgeTextPaint.setColor(Color.WHITE);
+ badgeTextPaint.setTypeface(Typeface.create("sans-serif-black", Typeface.BOLD));
+ badgeTextPaint.setFakeBoldText(true); // Force extra thickness
+ badgeTextPaint.setTextAlign(Paint.Align.CENTER);
+ badgeTextPaint.setTextSize(45f);
+
+ Paint.FontMetrics badgeMetrics = badgeTextPaint.getFontMetrics();
+ float badgeY = cy - (badgeMetrics.descent + badgeMetrics.ascent) / 2f;
+ canvas.drawText(badge, cx, badgeY, badgeTextPaint);
+ }
+
+ return Icon.createWithBitmap(bitmap);
+ }
+
+ public static Icon getCachedIcon(String text, String badge, boolean isAuto) {
+ String cacheKey = text + "_" + badge + "_" + isAuto;
+ return iconCache.computeIfAbsent(cacheKey, k -> createTextOnlyIcon(text, badge, isAuto));
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/util/AppExecutors.java b/app/src/main/java/com/dhangofa/networktoggle/util/AppExecutors.java
new file mode 100644
index 00000000..6039373c
--- /dev/null
+++ b/app/src/main/java/com/dhangofa/networktoggle/util/AppExecutors.java
@@ -0,0 +1,24 @@
+package com.dhangofa.networktoggle.util;
+
+/** Central executor for serialized, short-lived telephony work. */
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+public final class AppExecutors {
+
+ private static final ThreadPoolExecutor TELEPHONY = new ThreadPoolExecutor(
+ 0, 1, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), runnable -> {
+ Thread thread = new Thread(runnable, "NetToggle-telephony");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ private AppExecutors() {}
+
+ public static void executeTelephony(Runnable task) {
+ if (task != null) {
+ TELEPHONY.execute(task);
+ }
+ }
+}
diff --git a/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java b/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java
index ad57e4af..ee5b07f8 100644
--- a/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java
+++ b/app/src/main/java/com/dhangofa/networktoggle/util/DiagnosticReporter.java
@@ -1,57 +1,138 @@
package com.dhangofa.networktoggle.util;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
import android.os.Build;
import com.dhangofa.networktoggle.config.AppPreferences;
import com.dhangofa.networktoggle.model.DiagnosticError;
+import com.dhangofa.networktoggle.model.ExecutionMode;
+import com.dhangofa.networktoggle.model.TargetSim;
import com.dhangofa.networktoggle.telephony.SimResolver;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import rikka.shizuku.Shizuku;
public class DiagnosticReporter {
- public static String generateReport(AppPreferences prefs, SimResolver simResolver) {
- DiagnosticError error = prefs.getLastError();
- if (error == null) {
- return "No recent errors recorded.";
- }
+ public static String generateReport(Context context, AppPreferences prefs, SimResolver simResolver) {
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("Generated: ").append(sdf.format(new Date())).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("Product: ").append(Build.PRODUCT).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("Android Version: ").append(Build.VERSION.RELEASE)
+ .append(" (SDK ").append(Build.VERSION.SDK_INT).append(")\n\n");
sb.append("[APP STATE]\n");
+ try {
+ PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
+ long versionCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? pInfo.getLongVersionCode() : pInfo.versionCode;
+ sb.append("App Version: ").append(pInfo.versionName).append(" (").append(versionCode).append(")\n");
+ } catch (Exception e) {
+ sb.append("App Version: Unknown\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("Exit Code: ").append(error.exitCode).append("\n\n");
-
- if (error.exceptionMessage != null && !error.exceptionMessage.isEmpty()) {
- sb.append("Exception Message:\n").append(error.exceptionMessage).append("\n\n");
+ TargetSim targetSim = prefs.getTargetSim();
+ sb.append("Target SIM Setting: ").append(targetSim.name()).append("\n");
+ if (targetSim == TargetSim.BOTH) {
+ SimResolver.SimInfo sim1 = simResolver.resolveTargetSimInfo(prefs.getExecutionMode(), TargetSim.SIM_1);
+ SimResolver.SimInfo sim2 = simResolver.resolveTargetSimInfo(prefs.getExecutionMode(), TargetSim.SIM_2);
+ sb.append("SIM 1: slot ").append(sim1 != null ? sim1.slotIndex : -1)
+ .append(", subId ").append(sim1 != null ? sim1.subId : -1)
+ .append(sim1 != null && sim1.carrierName != null && !sim1.carrierName.isEmpty() ? " (" + sim1.carrierName + ")" : "")
+ .append("\n");
+ sb.append("SIM 2: slot ").append(sim2 != null ? sim2.slotIndex : -1)
+ .append(", subId ").append(sim2 != null ? sim2.subId : -1)
+ .append(sim2 != null && sim2.carrierName != null && !sim2.carrierName.isEmpty() ? " (" + sim2.carrierName + ")" : "")
+ .append("\n\n");
+ } else {
+ int slotIndex = simResolver.resolveTargetSlotIndex(prefs.getExecutionMode());
+ sb.append("Resolved Slot Index: ").append(slotIndex).append("\n\n");
}
- if (error.stdout != null && !error.stdout.isEmpty()) {
- sb.append("Standard Output (stdout):\n").append(error.stdout).append("\n\n");
+ ExecutionMode selectedMode = prefs.getExecutionMode();
+ if (selectedMode == ExecutionMode.ROOT) {
+ sb.append("[ROOT STATUS]\n");
+ Process process = null;
+ try {
+ process = Runtime.getRuntime().exec(new String[]{"su", "-c", "id"});
+ int exitCode = -1;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ boolean finished = process.waitFor(2, TimeUnit.SECONDS);
+ if (finished) {
+ exitCode = process.exitValue();
+ } else {
+ process.destroy();
+ }
+ } else {
+ exitCode = process.waitFor();
+ }
+ boolean granted = (exitCode == 0);
+ sb.append("Root Access: ").append(granted ? "Granted" : "Denied/Unavailable").append("\n");
+ if (granted) {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line = reader.readLine();
+ if (line != null && !line.trim().isEmpty()) {
+ sb.append("Identity: ").append(line.trim()).append("\n");
+ }
+ }
+ } else {
+ sb.append("Exit Code: ").append(exitCode).append("\n");
+ }
+ } catch (Throwable e) {
+ sb.append("Root Access: Unavailable (").append(e.getClass().getSimpleName()).append(")\n");
+ } finally {
+ if (process != null) {
+ process.destroy();
+ }
+ }
+ sb.append("\n");
+ } else if (selectedMode == ExecutionMode.SHIZUKU) {
+ sb.append("[SHIZUKU STATUS]\n");
+ try {
+ boolean isBinderAlive = Shizuku.pingBinder();
+ sb.append("Binder Alive: ").append(isBinderAlive).append("\n");
+ if (isBinderAlive) {
+ sb.append("Shizuku Version: ").append(Shizuku.getVersion()).append("\n");
+ boolean hasPermission = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED;
+ sb.append("Permission Granted: ").append(hasPermission).append("\n");
+ }
+ } catch (Throwable e) {
+ sb.append("Status: Error reading Shizuku status (").append(e.getClass().getSimpleName()).append(")\n");
+ }
+ sb.append("\n");
}
- sb.append("Standard Error (stderr):\n").append(error.stderr != null && !error.stderr.isEmpty() ? error.stderr : "(empty)").append("\n\n");
+ sb.append("[LAST ERROR EVENT]\n");
+ DiagnosticError error = prefs.getLastError();
+ if (error != null) {
+ sb.append("Time: ").append(sdf.format(new Date(error.timestamp))).append("\n\n");
+ sb.append("Command Attempted:\n").append(error.command != null ? error.command : "N/A").append("\n\n");
+ sb.append("Exit Code: ").append(error.exitCode).append("\n\n");
+ if (error.exceptionMessage != null && !error.exceptionMessage.isEmpty()) {
+ sb.append("Exception Message:\n").append(error.exceptionMessage).append("\n\n");
+ }
+ if (error.stdout != null && !error.stdout.isEmpty()) {
+ sb.append("Standard Output (stdout):\n").append(error.stdout).append("\n\n");
+ }
+ sb.append("Standard Error (stderr):\n").append(error.stderr != null && !error.stderr.isEmpty() ? error.stderr : "(empty)").append("\n");
+ } else {
+ sb.append("No recent errors recorded.\n");
+ }
- sb.append("--- END OF REPORT ---");
+ sb.append("\n--- END OF REPORT ---");
return sb.toString();
}
}
diff --git a/app/src/main/res/anim/bottom_sheet_enter.xml b/app/src/main/res/anim/bottom_sheet_enter.xml
new file mode 100644
index 00000000..375d5e4b
--- /dev/null
+++ b/app/src/main/res/anim/bottom_sheet_enter.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/anim/bottom_sheet_exit.xml b/app/src/main/res/anim/bottom_sheet_exit.xml
new file mode 100644
index 00000000..3a270cde
--- /dev/null
+++ b/app/src/main/res/anim/bottom_sheet_exit.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/animator/anim_guide_card_popup.xml b/app/src/main/res/animator/anim_guide_card_popup.xml
new file mode 100644
index 00000000..a63325b6
--- /dev/null
+++ b/app/src/main/res/animator/anim_guide_card_popup.xml
@@ -0,0 +1,41 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/color/color_segmented_text_broadcasts.xml b/app/src/main/res/color/color_segmented_text_broadcasts.xml
new file mode 100644
index 00000000..08c492d7
--- /dev/null
+++ b/app/src/main/res/color/color_segmented_text_broadcasts.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/color/color_segmented_text_cycle.xml b/app/src/main/res/color/color_segmented_text_cycle.xml
index d9152f60..732eaecf 100644
--- a/app/src/main/res/color/color_segmented_text_cycle.xml
+++ b/app/src/main/res/color/color_segmented_text_cycle.xml
@@ -1,5 +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
index 49c7c53b..50324b95 100644
--- a/app/src/main/res/color/color_segmented_text_exec.xml
+++ b/app/src/main/res/color/color_segmented_text_exec.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/app/src/main/res/color/color_segmented_text_sim.xml b/app/src/main/res/color/color_segmented_text_sim.xml
new file mode 100644
index 00000000..afd31d29
--- /dev/null
+++ b/app/src/main/res/color/color_segmented_text_sim.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml b/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml
index 6b78462d..8fde4563 100644
--- a/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,5 @@
+
diff --git a/app/src/main/res/drawable/bg_edge_nav.xml b/app/src/main/res/drawable/bg_edge_nav.xml
new file mode 100644
index 00000000..c94872a4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_edge_nav.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_qs_hint_bg.xml b/app/src/main/res/drawable/bg_nav_item_selected.xml
similarity index 75%
rename from app/src/main/res/drawable/shape_qs_hint_bg.xml
rename to app/src/main/res/drawable/bg_nav_item_selected.xml
index 8f2c9884..a9b6f8f2 100644
--- a/app/src/main/res/drawable/shape_qs_hint_bg.xml
+++ b/app/src/main/res/drawable/bg_nav_item_selected.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/main/res/drawable/bg_pill_nav.xml b/app/src/main/res/drawable/bg_pill_nav.xml
new file mode 100644
index 00000000..d2778ca8
--- /dev/null
+++ b/app/src/main/res/drawable/bg_pill_nav.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_network_bars.xml b/app/src/main/res/drawable/ic_add.xml
similarity index 61%
rename from app/src/main/res/drawable/ic_network_bars.xml
rename to app/src/main/res/drawable/ic_add.xml
index d92514f7..5fb62556 100644
--- a/app/src/main/res/drawable/ic_network_bars.xml
+++ b/app/src/main/res/drawable/ic_add.xml
@@ -5,6 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:fillColor="#FFFFFF"
+ android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
diff --git a/app/src/main/res/drawable/ic_bolt.xml b/app/src/main/res/drawable/ic_bolt.xml
new file mode 100644
index 00000000..38b58cc1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bolt.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_broadcast.xml b/app/src/main/res/drawable/ic_broadcast.xml
new file mode 100644
index 00000000..108db017
--- /dev/null
+++ b/app/src/main/res/drawable/ic_broadcast.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml
new file mode 100644
index 00000000..5c1ad0e2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chevron_right.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml
new file mode 100644
index 00000000..10322832
--- /dev/null
+++ b/app/src/main/res/drawable/ic_copy.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 00000000..120e701d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_hash.xml b/app/src/main/res/drawable/ic_hash.xml
new file mode 100644
index 00000000..aaa58993
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hash.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_key_shield.xml b/app/src/main/res/drawable/ic_key_shield.xml
new file mode 100644
index 00000000..ea5625f8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_key_shield.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
index e4221633..447eb2cf 100644
--- a/app/src/main/res/drawable/ic_launcher.xml
+++ b/app/src/main/res/drawable/ic_launcher.xml
@@ -1,48 +1,266 @@
+
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ android:fillColor="#400A1204"
+ android:pathData="M55,28 L53,28 A2,2 0 0,0 51,30 L51,42 L42,42 A2,2 0 0,0 40,44 L40,78 A2,2 0 0,0 42,80 L66,80 A2,2 0 0,0 68,78 L68,44 A2,2 0 0,0 66,42 L57,42 L57,30 A2,2 0 0,0 55,28 Z M47,49 L61,49 L61,73 L47,73 Z M32,46 A2,2 0 0,0 30,48 C30,58 34,68 40,75 A2,2 0 0,0 43,72 C37,66 34,58 34,48 A2,2 0 0,0 32,46 Z M76,46 A2,2 0 0,0 74,48 C74,58 71,66 65,72 A2,2 0 0,0 68,75 C74,68 78,58 78,48 A2,2 0 0,0 76,46 Z M23,36 A2,2 0 0,0 21,38 C21,52 26,65 35,76 A2,2 0 0,0 38,73 C29,63 25,51 25,38 A2,2 0 0,0 23,36 Z M85,36 A2,2 0 0,0 83,38 C83,51 79,63 70,73 A2,2 0 0,0 73,76 C82,65 87,52 87,38 A2,2 0 0,0 85,36 Z" />
-
+
+ android:pathData="M55,28 L53,28 A2,2 0 0,0 51,30 L51,42 L42,42 A2,2 0 0,0 40,44 L40,78 A2,2 0 0,0 42,80 L66,80 A2,2 0 0,0 68,78 L68,44 A2,2 0 0,0 66,42 L57,42 L57,30 A2,2 0 0,0 55,28 Z M47,49 L61,49 L61,73 L47,73 Z M32,46 A2,2 0 0,0 30,48 C30,58 34,68 40,75 A2,2 0 0,0 43,72 C37,66 34,58 34,48 A2,2 0 0,0 32,46 Z M76,46 A2,2 0 0,0 74,48 C74,58 71,66 65,72 A2,2 0 0,0 68,75 C74,68 78,58 78,48 A2,2 0 0,0 76,46 Z M23,36 A2,2 0 0,0 21,38 C21,52 26,65 35,76 A2,2 0 0,0 38,73 C29,63 25,51 25,38 A2,2 0 0,0 23,36 Z M85,36 A2,2 0 0,0 83,38 C83,51 79,63 70,73 A2,2 0 0,0 73,76 C82,65 87,52 87,38 A2,2 0 0,0 85,36 Z" />
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index da555f46..81d7e279 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -5,18 +5,35 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
+
+
-
-
-
-
+ android:endY="108">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index c7c32071..9cd35829 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,28 +1,25 @@
+
-
-
+
-
-
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 00000000..fe62a0c5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_lightbulb_glow.xml b/app/src/main/res/drawable/ic_lightbulb_glow.xml
new file mode 100644
index 00000000..382d05cd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lightbulb_glow.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml
new file mode 100644
index 00000000..16f2b22f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lock.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_logo.xml b/app/src/main/res/drawable/ic_logo.xml
new file mode 100644
index 00000000..b85277c9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_logo.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_logo_ui.xml b/app/src/main/res/drawable/ic_logo_ui.xml
deleted file mode 100644
index 852af25d..00000000
--- a/app/src/main/res/drawable/ic_logo_ui.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_magic_wand.xml b/app/src/main/res/drawable/ic_magic_wand.xml
new file mode 100644
index 00000000..eb093cc6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_magic_wand.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_automate_filled.xml b/app/src/main/res/drawable/ic_nav_automate_filled.xml
new file mode 100644
index 00000000..9bceead0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_automate_filled.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_automate_outline.xml b/app/src/main/res/drawable/ic_nav_automate_outline.xml
new file mode 100644
index 00000000..6af5cda3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_automate_outline.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_guides_filled.xml b/app/src/main/res/drawable/ic_nav_guides_filled.xml
new file mode 100644
index 00000000..2b8862cc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_guides_filled.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_guides_outline.xml b/app/src/main/res/drawable/ic_nav_guides_outline.xml
new file mode 100644
index 00000000..e03313be
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_guides_outline.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_home_filled.xml b/app/src/main/res/drawable/ic_nav_home_filled.xml
new file mode 100644
index 00000000..d47dfc4e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_home_filled.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_home_outline.xml b/app/src/main/res/drawable/ic_nav_home_outline.xml
new file mode 100644
index 00000000..4ee9f0e2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_home_outline.xml
@@ -0,0 +1,20 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_pin_shortcut.xml b/app/src/main/res/drawable/ic_pin_shortcut.xml
new file mode 100644
index 00000000..93a74eaa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pin_shortcut.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml
new file mode 100644
index 00000000..3e97f486
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_arrow.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_qs_logo.xml b/app/src/main/res/drawable/ic_qs_logo.xml
new file mode 100644
index 00000000..5e6459c5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qs_logo.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_qs_network.xml b/app/src/main/res/drawable/ic_qs_network.xml
deleted file mode 100644
index a5be7ca7..00000000
--- a/app/src/main/res/drawable/ic_qs_network.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_quick_tile.xml b/app/src/main/res/drawable/ic_quick_tile.xml
new file mode 100644
index 00000000..264f4262
--- /dev/null
+++ b/app/src/main/res/drawable/ic_quick_tile.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sim_card.xml b/app/src/main/res/drawable/ic_sim_card.xml
index fd098565..31f761e8 100644
--- a/app/src/main/res/drawable/ic_sim_card.xml
+++ b/app/src/main/res/drawable/ic_sim_card.xml
@@ -6,5 +6,5 @@
android:viewportHeight="24">
+ android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM9,19H7v-2h2v2zm0,-4H7v-4h2v4zm4,4h-2v-4h2v4zm0,-6h-2v-2h2v2zm4,6h-2v-2h2v2zm0,-4h-2v-4h2v4z" />
diff --git a/app/src/main/res/drawable/ic_theme_amoled.xml b/app/src/main/res/drawable/ic_theme_amoled.xml
new file mode 100644
index 00000000..ee0b4d63
--- /dev/null
+++ b/app/src/main/res/drawable/ic_theme_amoled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_theme_auto.xml b/app/src/main/res/drawable/ic_theme_auto.xml
new file mode 100644
index 00000000..af213e76
--- /dev/null
+++ b/app/src/main/res/drawable/ic_theme_auto.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_theme_dark.xml b/app/src/main/res/drawable/ic_theme_dark.xml
new file mode 100644
index 00000000..b46c30a9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_theme_dark.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_theme_light.xml b/app/src/main/res/drawable/ic_theme_light.xml
new file mode 100644
index 00000000..e1da7617
--- /dev/null
+++ b/app/src/main/res/drawable/ic_theme_light.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_cycle.xml b/app/src/main/res/drawable/selector_segmented_tab_cycle.xml
index 89b5b3f3..85cba2fe 100644
--- a/app/src/main/res/drawable/selector_segmented_tab_cycle.xml
+++ b/app/src/main/res/drawable/selector_segmented_tab_cycle.xml
@@ -1,15 +1,20 @@
-
- -
-
-
-
-
-
+
-
-
-
-
-
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
-
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_exec.xml b/app/src/main/res/drawable/selector_segmented_tab_exec.xml
index 28f41d04..bb79322f 100644
--- a/app/src/main/res/drawable/selector_segmented_tab_exec.xml
+++ b/app/src/main/res/drawable/selector_segmented_tab_exec.xml
@@ -1,15 +1,20 @@
-
- -
-
-
-
-
-
+
-
-
-
-
-
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
-
+
diff --git a/app/src/main/res/drawable/selector_segmented_tab_sim.xml b/app/src/main/res/drawable/selector_segmented_tab_sim.xml
new file mode 100644
index 00000000..00054d46
--- /dev/null
+++ b/app/src/main/res/drawable/selector_segmented_tab_sim.xml
@@ -0,0 +1,20 @@
+
+
+ -
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_switch_thumb.xml b/app/src/main/res/drawable/selector_switch_thumb.xml
new file mode 100644
index 00000000..54ce8006
--- /dev/null
+++ b/app/src/main/res/drawable/selector_switch_thumb.xml
@@ -0,0 +1,32 @@
+
+
+ -
+
+ -
+
+
+
+
+
+
+
+ -
+
+ -
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_switch_track_broadcast.xml b/app/src/main/res/drawable/selector_switch_track_broadcast.xml
new file mode 100644
index 00000000..e2fd4e3f
--- /dev/null
+++ b/app/src/main/res/drawable/selector_switch_track_broadcast.xml
@@ -0,0 +1,18 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_switch_track_cycle.xml b/app/src/main/res/drawable/selector_switch_track_cycle.xml
new file mode 100644
index 00000000..86b85a68
--- /dev/null
+++ b/app/src/main/res/drawable/selector_switch_track_cycle.xml
@@ -0,0 +1,18 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ripple_pill_mask.xml b/app/src/main/res/drawable/selector_theme_toggle_ripple.xml
similarity index 77%
rename from app/src/main/res/drawable/ripple_pill_mask.xml
rename to app/src/main/res/drawable/selector_theme_toggle_ripple.xml
index 3a69113e..3bf17d67 100644
--- a/app/src/main/res/drawable/ripple_pill_mask.xml
+++ b/app/src/main/res/drawable/selector_theme_toggle_ripple.xml
@@ -2,8 +2,7 @@
-
-
-
+
diff --git a/app/src/main/res/drawable/shape_badge_locked.xml b/app/src/main/res/drawable/shape_badge_locked.xml
new file mode 100644
index 00000000..5989fda0
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_locked.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_badge_pending_auth.xml b/app/src/main/res/drawable/shape_badge_pending_auth.xml
new file mode 100644
index 00000000..c7127d62
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_pending_auth.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_badge_recommended.xml b/app/src/main/res/drawable/shape_badge_recommended.xml
new file mode 100644
index 00000000..cec9a44f
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_recommended.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_badge_slot_green.xml b/app/src/main/res/drawable/shape_badge_slot_green.xml
new file mode 100644
index 00000000..fbf5c9a7
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_slot_green.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_badge_slot_purple.xml b/app/src/main/res/drawable/shape_badge_slot_purple.xml
new file mode 100644
index 00000000..2df3c056
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_slot_purple.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_badge_superuser.xml b/app/src/main/res/drawable/shape_badge_superuser.xml
new file mode 100644
index 00000000..94efc60e
--- /dev/null
+++ b/app/src/main/res/drawable/shape_badge_superuser.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_banner_limit_reached.xml b/app/src/main/res/drawable/shape_banner_limit_reached.xml
new file mode 100644
index 00000000..3cd4f60a
--- /dev/null
+++ b/app/src/main/res/drawable/shape_banner_limit_reached.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_btn_chip.xml b/app/src/main/res/drawable/shape_btn_chip.xml
new file mode 100644
index 00000000..6f381f3b
--- /dev/null
+++ b/app/src/main/res/drawable/shape_btn_chip.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_btn_pin_shortcut.xml b/app/src/main/res/drawable/shape_btn_pin_shortcut.xml
new file mode 100644
index 00000000..01736ac1
--- /dev/null
+++ b/app/src/main/res/drawable/shape_btn_pin_shortcut.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_btn_test_broadcast.xml b/app/src/main/res/drawable/shape_btn_test_broadcast.xml
new file mode 100644
index 00000000..711ad47a
--- /dev/null
+++ b/app/src/main/res/drawable/shape_btn_test_broadcast.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_button_primary.xml b/app/src/main/res/drawable/shape_button_primary.xml
index efa2239d..aed6dcae 100644
--- a/app/src/main/res/drawable/shape_button_primary.xml
+++ b/app/src/main/res/drawable/shape_button_primary.xml
@@ -1,5 +1,9 @@
-
-
-
-
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_floating_footer.xml b/app/src/main/res/drawable/shape_card_surface.xml
similarity index 83%
rename from app/src/main/res/drawable/shape_floating_footer.xml
rename to app/src/main/res/drawable/shape_card_surface.xml
index f420756d..95a2d816 100644
--- a/app/src/main/res/drawable/shape_floating_footer.xml
+++ b/app/src/main/res/drawable/shape_card_surface.xml
@@ -2,5 +2,6 @@
-
+
+
diff --git a/app/src/main/res/drawable/shape_chip_selected_green.xml b/app/src/main/res/drawable/shape_chip_selected_green.xml
new file mode 100644
index 00000000..8f8c83f3
--- /dev/null
+++ b/app/src/main/res/drawable/shape_chip_selected_green.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_chip_selected_orange.xml b/app/src/main/res/drawable/shape_chip_selected_orange.xml
new file mode 100644
index 00000000..5e99596f
--- /dev/null
+++ b/app/src/main/res/drawable/shape_chip_selected_orange.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_chip_unselected.xml b/app/src/main/res/drawable/shape_chip_unselected.xml
new file mode 100644
index 00000000..ca559179
--- /dev/null
+++ b/app/src/main/res/drawable/shape_chip_unselected.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_count_badge_bg.xml b/app/src/main/res/drawable/shape_count_badge_bg.xml
new file mode 100644
index 00000000..aec85644
--- /dev/null
+++ b/app/src/main/res/drawable/shape_count_badge_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_cycle_order_badge.xml b/app/src/main/res/drawable/shape_cycle_order_badge.xml
index e8ebe005..8feb24a4 100644
--- a/app/src/main/res/drawable/shape_cycle_order_badge.xml
+++ b/app/src/main/res/drawable/shape_cycle_order_badge.xml
@@ -1,6 +1,9 @@
-
-
-
-
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_cycle_segmented_container_rect.xml b/app/src/main/res/drawable/shape_cycle_segmented_container_rect.xml
new file mode 100644
index 00000000..3e454a5a
--- /dev/null
+++ b/app/src/main/res/drawable/shape_cycle_segmented_container_rect.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_dialog_bg.xml b/app/src/main/res/drawable/shape_dialog_bg.xml
index b9a57cdc..6022dcee 100644
--- a/app/src/main/res/drawable/shape_dialog_bg.xml
+++ b/app/src/main/res/drawable/shape_dialog_bg.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/main/res/drawable/shape_dot_active.xml b/app/src/main/res/drawable/shape_dot_active.xml
deleted file mode 100644
index cd0e8862..00000000
--- a/app/src/main/res/drawable/shape_dot_active.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/shape_dot_inactive.xml b/app/src/main/res/drawable/shape_dot_inactive.xml
deleted file mode 100644
index bf61b4d6..00000000
--- a/app/src/main/res/drawable/shape_dot_inactive.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/shape_exec_segmented_container.xml b/app/src/main/res/drawable/shape_exec_segmented_container.xml
new file mode 100644
index 00000000..a53ea06a
--- /dev/null
+++ b/app/src/main/res/drawable/shape_exec_segmented_container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_fab_wavy.xml b/app/src/main/res/drawable/shape_fab_wavy.xml
new file mode 100644
index 00000000..b6c6fd2b
--- /dev/null
+++ b/app/src/main/res/drawable/shape_fab_wavy.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_fab_wavy_amoled.xml b/app/src/main/res/drawable/shape_fab_wavy_amoled.xml
new file mode 100644
index 00000000..5a57e9f0
--- /dev/null
+++ b/app/src/main/res/drawable/shape_fab_wavy_amoled.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_guide_card_bg.xml b/app/src/main/res/drawable/shape_guide_card_bg.xml
new file mode 100644
index 00000000..4db673f1
--- /dev/null
+++ b/app/src/main/res/drawable/shape_guide_card_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_guide_pill_btn.xml b/app/src/main/res/drawable/shape_guide_pill_btn.xml
new file mode 100644
index 00000000..97a15312
--- /dev/null
+++ b/app/src/main/res/drawable/shape_guide_pill_btn.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_guides_developer_pill.xml b/app/src/main/res/drawable/shape_guides_developer_pill.xml
new file mode 100644
index 00000000..bb7752fc
--- /dev/null
+++ b/app/src/main/res/drawable/shape_guides_developer_pill.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_guides_logo_squircle.xml b/app/src/main/res/drawable/shape_guides_logo_squircle.xml
new file mode 100644
index 00000000..52035045
--- /dev/null
+++ b/app/src/main/res/drawable/shape_guides_logo_squircle.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_guides_version_badge.xml b/app/src/main/res/drawable/shape_guides_version_badge.xml
new file mode 100644
index 00000000..0a00e22b
--- /dev/null
+++ b/app/src/main/res/drawable/shape_guides_version_badge.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_input_bg.xml b/app/src/main/res/drawable/shape_input_bg.xml
new file mode 100644
index 00000000..f6515ca4
--- /dev/null
+++ b/app/src/main/res/drawable/shape_input_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_option_icon_bg.xml b/app/src/main/res/drawable/shape_option_icon_bg.xml
new file mode 100644
index 00000000..12267798
--- /dev/null
+++ b/app/src/main/res/drawable/shape_option_icon_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_radio_selected.xml b/app/src/main/res/drawable/shape_radio_selected.xml
new file mode 100644
index 00000000..d2d270cb
--- /dev/null
+++ b/app/src/main/res/drawable/shape_radio_selected.xml
@@ -0,0 +1,18 @@
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_radio_unselected.xml b/app/src/main/res/drawable/shape_radio_unselected.xml
new file mode 100644
index 00000000..8ca95381
--- /dev/null
+++ b/app/src/main/res/drawable/shape_radio_unselected.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_setup_action_btn.xml b/app/src/main/res/drawable/shape_setup_action_btn.xml
new file mode 100644
index 00000000..943b98ac
--- /dev/null
+++ b/app/src/main/res/drawable/shape_setup_action_btn.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_small_divider.xml b/app/src/main/res/drawable/shape_setup_icon_bg.xml
similarity index 61%
rename from app/src/main/res/drawable/shape_small_divider.xml
rename to app/src/main/res/drawable/shape_setup_icon_bg.xml
index 9ee6d7c0..87b8f86d 100644
--- a/app/src/main/res/drawable/shape_small_divider.xml
+++ b/app/src/main/res/drawable/shape_setup_icon_bg.xml
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/shape_setup_option_selected.xml b/app/src/main/res/drawable/shape_setup_option_selected.xml
new file mode 100644
index 00000000..c39c68b3
--- /dev/null
+++ b/app/src/main/res/drawable/shape_setup_option_selected.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_setup_option_unselected.xml b/app/src/main/res/drawable/shape_setup_option_unselected.xml
new file mode 100644
index 00000000..b14f6d78
--- /dev/null
+++ b/app/src/main/res/drawable/shape_setup_option_unselected.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_segmented_container_rect.xml b/app/src/main/res/drawable/shape_shortcut_tip_bg.xml
similarity index 76%
rename from app/src/main/res/drawable/shape_segmented_container_rect.xml
rename to app/src/main/res/drawable/shape_shortcut_tip_bg.xml
index 9047d421..67913675 100644
--- a/app/src/main/res/drawable/shape_segmented_container_rect.xml
+++ b/app/src/main/res/drawable/shape_shortcut_tip_bg.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/main/res/drawable/shape_segmented_container.xml b/app/src/main/res/drawable/shape_sim_segmented_container.xml
similarity index 75%
rename from app/src/main/res/drawable/shape_segmented_container.xml
rename to app/src/main/res/drawable/shape_sim_segmented_container.xml
index 3537330b..b63364fb 100644
--- a/app/src/main/res/drawable/shape_segmented_container.xml
+++ b/app/src/main/res/drawable/shape_sim_segmented_container.xml
@@ -1,7 +1,6 @@
-
+
-
diff --git a/app/src/main/res/drawable/shape_status_badge_disabled.xml b/app/src/main/res/drawable/shape_status_badge_disabled.xml
new file mode 100644
index 00000000..c2ceab80
--- /dev/null
+++ b/app/src/main/res/drawable/shape_status_badge_disabled.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_status_badge_error.xml b/app/src/main/res/drawable/shape_status_badge_error.xml
index 697a2333..f2cb0570 100644
--- a/app/src/main/res/drawable/shape_status_badge_error.xml
+++ b/app/src/main/res/drawable/shape_status_badge_error.xml
@@ -1,6 +1,9 @@
-
-
-
-
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_status_badge_ready.xml b/app/src/main/res/drawable/shape_status_badge_ready.xml
new file mode 100644
index 00000000..5fabbf9c
--- /dev/null
+++ b/app/src/main/res/drawable/shape_status_badge_ready.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_status_badge_success.xml b/app/src/main/res/drawable/shape_status_badge_success.xml
index 5dc23a25..00b2e2f4 100644
--- a/app/src/main/res/drawable/shape_status_badge_success.xml
+++ b/app/src/main/res/drawable/shape_status_badge_success.xml
@@ -1,6 +1,9 @@
-
-
-
-
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_status_badge_warning.xml b/app/src/main/res/drawable/shape_status_badge_warning.xml
new file mode 100644
index 00000000..79e5beeb
--- /dev/null
+++ b/app/src/main/res/drawable/shape_status_badge_warning.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_target_sim_card_bg.xml b/app/src/main/res/drawable/shape_target_sim_card_bg.xml
new file mode 100644
index 00000000..130b0280
--- /dev/null
+++ b/app/src/main/res/drawable/shape_target_sim_card_bg.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shape_terminal_bg.xml b/app/src/main/res/drawable/shape_terminal_bg.xml
new file mode 100644
index 00000000..74d32f30
--- /dev/null
+++ b/app/src/main/res/drawable/shape_terminal_bg.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/social_icon_bg.xml b/app/src/main/res/drawable/social_icon_bg.xml
index fd5528c2..24e0230c 100644
--- a/app/src/main/res/drawable/social_icon_bg.xml
+++ b/app/src/main/res/drawable/social_icon_bg.xml
@@ -1,6 +1,10 @@
-
-
-
-
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml
new file mode 100644
index 00000000..5f2216e4
--- /dev/null
+++ b/app/src/main/res/drawable/splash_background.xml
@@ -0,0 +1,55 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml
new file mode 100644
index 00000000..e84ddc42
--- /dev/null
+++ b/app/src/main/res/drawable/splash_icon.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml
index 5ccb2edf..2cdbdc78 100644
--- a/app/src/main/res/layout-land/activity_main.xml
+++ b/app/src/main/res/layout-land/activity_main.xml
@@ -1,445 +1,164 @@
-
-
+
-
-
-
+
+
+ android:layout_gravity="center">
-
-
-
-
-
-
-
-
+
+
-
-
+
-
-
-
-
+ android:orientation="vertical"
+ android:gravity="center_horizontal">
+
+
+
-
-
-
+
+
+
+
-
+
+
+
+
+
+
-
+
+
-
+ android:id="@+id/iconNavGuides"
+ android:layout_width="22dp"
+ android:layout_height="22dp"
+ android:src="@drawable/ic_nav_guides_outline"
+ android:tint="@color/nav_item_unselected_icon"
+ android:contentDescription="@string/nav_tab_guides" />
+
+
-
-
-
+
+
+
+
+
+ android:orientation="vertical">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_height="match_parent">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
diff --git a/app/src/main/res/layout-land/layout_tab_automation.xml b/app/src/main/res/layout-land/layout_tab_automation.xml
new file mode 100644
index 00000000..62a52d96
--- /dev/null
+++ b/app/src/main/res/layout-land/layout_tab_automation.xml
@@ -0,0 +1,971 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/layout_tab_guides.xml b/app/src/main/res/layout-land/layout_tab_guides.xml
new file mode 100644
index 00000000..c689efc5
--- /dev/null
+++ b/app/src/main/res/layout-land/layout_tab_guides.xml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/layout_tab_home.xml b/app/src/main/res/layout-land/layout_tab_home.xml
new file mode 100644
index 00000000..f8345967
--- /dev/null
+++ b/app/src/main/res/layout-land/layout_tab_home.xml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 67f0e0c3..c968e135 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,326 +1,164 @@
-
-
-
+
+ android:layout_height="match_parent"
+ android:layout_alignParentTop="true">
-
-
-
-
-
+
+
+
-
-
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+ android:layout_marginStart="6dp"
+ android:text="@string/nav_home"
+ android:textStyle="bold"
+ android:singleLine="true"
+ android:ellipsize="none"
+ android:visibility="visible"
+ android:textColor="@color/nav_item_selected_content"
+ android:textSize="13sp" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:focusable="true">
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/bottom_sheet_permission.xml b/app/src/main/res/layout/bottom_sheet_permission.xml
index 4f473b8e..769d1040 100644
--- a/app/src/main/res/layout/bottom_sheet_permission.xml
+++ b/app/src/main/res/layout/bottom_sheet_permission.xml
@@ -14,6 +14,7 @@
android:layout_marginBottom="16dp">
+ android:gravity="center_vertical">
diff --git a/app/src/main/res/layout/dialog_diagnostic.xml b/app/src/main/res/layout/dialog_diagnostic.xml
index e348fcd4..db358b4f 100644
--- a/app/src/main/res/layout/dialog_diagnostic.xml
+++ b/app/src/main/res/layout/dialog_diagnostic.xml
@@ -11,7 +11,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_tab_automation.xml b/app/src/main/res/layout/layout_tab_automation.xml
new file mode 100644
index 00000000..abf31934
--- /dev/null
+++ b/app/src/main/res/layout/layout_tab_automation.xml
@@ -0,0 +1,911 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_tab_guides.xml b/app/src/main/res/layout/layout_tab_guides.xml
new file mode 100644
index 00000000..5026ab59
--- /dev/null
+++ b/app/src/main/res/layout/layout_tab_guides.xml
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_tab_home.xml b/app/src/main/res/layout/layout_tab_home.xml
new file mode 100644
index 00000000..450f2670
--- /dev/null
+++ b/app/src/main/res/layout/layout_tab_home.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_error_banner.xml b/app/src/main/res/layout/view_error_banner.xml
index ee18cb21..f3215327 100644
--- a/app/src/main/res/layout/view_error_banner.xml
+++ b/app/src/main/res/layout/view_error_banner.xml
@@ -16,7 +16,7 @@
android:src="@drawable/ic_warning"
android:tint="@color/status_error_text"
android:layout_marginEnd="10dp"
- android:contentDescription="Error Icon" />
+ android:contentDescription="@string/error_icon" />
-
@@ -69,7 +71,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
- android:background="@drawable/shape_segmented_container"
+ android:background="@drawable/shape_exec_segmented_container"
android:padding="4dp">
+ android:background="@color/exec_segmented_separator" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_setup_hero.xml b/app/src/main/res/layout/view_setup_hero.xml
new file mode 100644
index 00000000..ad04a09a
--- /dev/null
+++ b/app/src/main/res/layout/view_setup_hero.xml
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_target_sim_card.xml b/app/src/main/res/layout/view_target_sim_card.xml
new file mode 100644
index 00000000..db408859
--- /dev/null
+++ b/app/src/main/res/layout/view_target_sim_card.xml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 7741af63..c76d78c0 100644
--- a/app/src/main/res/layout/view_tile_cycle_card.xml
+++ b/app/src/main/res/layout/view_tile_cycle_card.xml
@@ -1,11 +1,11 @@
+ android:orientation="vertical">
@@ -23,39 +25,78 @@
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="6dp"
- android:contentDescription="Quick Tile Cycle"
+ android:contentDescription="@string/quick_tile_cycle"
android:src="@drawable/ic_refresh"
- android:tint="@color/accent_orange" />
+ android:tint="@color/cycle_accent" />
-
+ android:layout_centerVertical="true"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
+
+
+
+
+
+
@@ -63,7 +104,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
- android:background="@drawable/shape_segmented_container_rect"
+ android:background="@drawable/shape_cycle_segmented_container_rect"
android:padding="4dp">
@@ -87,7 +128,7 @@
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
- android:background="@color/segmented_separator" />
+ android:background="@color/cycle_segmented_separator" />
@@ -110,7 +151,7 @@
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
- android:background="@color/segmented_separator" />
+ android:background="@color/cycle_segmented_separator" />
@@ -133,7 +174,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
- android:background="@drawable/shape_segmented_container_rect"
+ android:background="@drawable/shape_cycle_segmented_container_rect"
android:padding="4dp">
@@ -157,7 +198,7 @@
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
- android:background="@color/segmented_separator" />
+ android:background="@color/cycle_segmented_separator" />
@@ -180,7 +221,7 @@
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
- android:background="@color/segmented_separator" />
+ android:background="@color/cycle_segmented_separator" />
@@ -206,9 +247,70 @@
android:background="@drawable/shape_cycle_order_badge"
android:gravity="center"
android:padding="10dp"
- android:text="Cycle: 5G Only → 4G Only → Preferred 5G"
- android:textColor="@color/accent_orange"
+ android:text="@string/cycle_5g_only_4g_only_preferred_5g"
+ android:textColor="@color/cycle_accent"
android:textSize="11sp"
android:textStyle="bold" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
new file mode 100644
index 00000000..0e484143
--- /dev/null
+++ b/app/src/main/res/values-bn/strings.xml
@@ -0,0 +1,205 @@
+
+
+ সর্বোচ্চ পারফরম্যান্স আনলক করুন
+ NetToggle দ্রুত কাজ করে যদি এটি সরাসরি আপনার সিমের অবস্থা পড়তে পারে। সিস্টেমের বিলম্ব দূর করতে এবং তাত্ক্ষণিক সমাধানের জন্য অনুগ্রহ করে ফোনের অনুমতি দিন।
+ বাতিল করুন
+ অনুমতি দিন
+ সেটিংস খুলুন
+ ফোন অনুমতি স্থায়ীভাবে অস্বীকার করা হয়েছে। সর্বোচ্চ কর্মক্ষমতা আনলক করতে অনুগ্রহ করে অ্যাপ সেটিংসে ফোন অনুমতি সক্ষম করুন।
+ রুটিন শর্টকাট
+ + শর্টকাট যোগ করুন
+ এক্সিকিউশন মোড
+ অনুমোদিত
+ সেটআপ প্রয়োজন
+ অনুমোদন অপেক্ষমাণ
+ আপনার বিশেষাধিকারপ্রাপ্ত এক্সিকিউশন মোড নির্বাচন করুন
+ কুইক সেটিংস মোবাইল নেটওয়ার্ক সুইচিং স্বয়ংক্রিয় করতে বর্ধিত অনুমতি প্রয়োজন:
+ Shizuku
+ প্রস্তাবিত
+ ওয়্যারলেস ADB ডিবাগিং • বুটলোডার আনলক বা রুটের প্রয়োজন নেই
+ পরিষেবা চালু আছে • অনুমতি দিতে প্রস্তুত
+ পরিষেবা বন্ধ • Shizuku অ্যাপের মাধ্যমে শুরু করুন
+ রুট অ্যাক্সেস
+ সুপারইউজার
+ Magisk, KernelSU বা APatch-এর মাধ্যমে সরাসরি সুবিধা
+ অনুমতি দেওয়া হয়নি • রুট ম্যানেজার অ্যাপে অনুমতি দিন
+ যাচাই করা হচ্ছে • রুট ম্যানেজার অ্যাপে প্রম্পট নিশ্চিত করুন...
+ প্রত্যাখ্যাত / পাওয়া যায়নি • রুট ম্যানেজারে অনুমতি দিন
+ রুট সক্রিয় এবং অনুমোদিত
+ রুট সুবিধা দেওয়া হয়নি। অনুগ্রহ করে আপনার রুট ম্যানেজার অ্যাপে সুপারইউজার অ্যাক্সেস দিন।
+ রুট মোড সক্রিয় এবং অনুমোদিত
+ রুট অনুমতি প্রত্যাখ্যাত বা অনুপলব্ধ
+ Shizuku অ্যাক্সেস অনুমোদন করুন
+ রুট সুবিধা দিন
+ সেটআপে সাহায্য দরকার? গাইড ট্যাব খুলুন →
+ লক করা
+ টার্গেট সিম কনফিগার করতে অনুমোদন প্রয়োজন। অনুগ্রহ করে উপরে Root অথবা Shizuku প্রদান করুন।
+ কুইক টাইল সাইকেল কনফিগার করতে অনুমোদন প্রয়োজন। অনুগ্রহ করে উপরে Root অথবা Shizuku প্রদান করুন।
+ Root
+ Shizuku
+ কুইক টাইল সাইকেল (Quick Tile)
+ 3/3
+ ২ থেকে ৩টি মোড নির্বাচন করুন। নির্বাচনের ক্রমটি টাইল সাইকেলের ক্রম হবে।
+ পছন্দের 5G
+ পছন্দের 4G
+ পছন্দের 3G
+ শুধুমাত্র 5G
+ শুধুমাত্র 4G
+ শুধুমাত্র 2G
+ সাইকেল: শুধুমাত্র 5G → শুধুমাত্র 4G → পছন্দের 5G
+ টার্গেট সিম
+ অটো ব্যর্থ
+ অটো
+ সিম ১
+ সিম ২
+ উভয়
+ ডায়াগনস্টিক রিপোর্ট
+ বন্ধ করুন
+ রিপোর্ট কপি করুন
+ NetToggle
+ Dhangofa
+ কমান্ড ব্যর্থ হয়েছে
+ ডায়াগনস্টিক রিপোর্ট দেখতে ট্যাপ করুন
+ সেটিংস কনফিগার করতে অনুগ্রহ করে Root অথবা Shizuku অনুমোদন করুন।
+ বাহ্যিক অটোমেশন সক্ষম করতে অনুগ্রহ করে Root বা Shizuku অনুমোদন করুন
+ শর্টকাট পরীক্ষা করতে অনুগ্রহ করে Root বা Shizuku অনুমোদন করুন
+ বাহ্যিক অটোমেশন নিষ্ক্রিয়। ব্রডকাস্ট পরীক্ষা করতে এটি সক্রিয় করুন
+ উভয় সিম কার্ড প্রয়োজন।
+ স্লট ১-এ কোনো সিম কার্ড পাওয়া যায়নি
+ স্লট ২-এ কোনো সিম কার্ড পাওয়া যায়নি
+ টার্গেট স্লটে কোনো সিম কার্ড পাওয়া যায়নি।
+ রিপোর্ট ক্লিপবোর্ডে কপি করা হয়েছে
+ অটো সিম শনাক্তকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে ম্যানুয়ালি সিম ১ বা সিম ২ নির্বাচন করুন।
+ নির্দেশিকা পড়ুন
+ এক্সিকিউশন মোড
+ Root অথবা Shizuku কনফিগার করুন
+ টার্গেট সিম এবং কুইক টাইল সাইকেল
+ ডুয়াল সিম এবং টাইল সাইকেল পরিচালনা করুন
+ কুইক টাইল
+ কুইক সেটিংসে যোগ করুন
+ অ্যাপ শর্টকাট
+ OEM রুটিন বা ১-ট্যাপ হোম স্ক্রিন শর্টকাট ব্যবহার করুন
+ ব্রডকাস্ট
+ Tasker, MacroDroid, Automate এবং বাহ্যিক স্ক্রিপ্ট
+ সমস্যা সমাধান
+ সাধারণ সমস্যা এবং প্রশ্নোত্তর সমাধান করুন
+ নির্দেশিকাসমূহ
+ নির্বাচিত: %1$d/3
+ Root অথবা Shizuku মোড নির্বাচন করুন।
+ v%1$s
+ GPL V3
+ আপনার ডিভাইস বা বর্তমান অপারেটর 5G সমর্থন করে না।
+ 3G নিষ্ক্রিয় বা আপনার অপারেটর দ্বারা সমর্থিত নয়।
+ 2G নিষ্ক্রিয় বা আপনার অপারেটর দ্বারা সমর্থিত নয়।
+ অন্তত ২টি মোড নির্বাচন করুন।
+ আপনি সর্বোচ্চ ৩টি মোড নির্বাচন করতে পারেন।
+ সাইকেল:
+ পরিবর্তন হচ্ছে...
+ Shizuku অনুপলব্ধ
+ Root অনুপলব্ধ
+ ত্রুটি (অ্যাপ চেক করুন)
+ সেটআপ প্রয়োজন
+ %1$s সেট করতে ট্যাপ করুন
+ সক্রিয় ডেটা সিম স্বয়ংক্রিয়ভাবে শনাক্ত করা যায়নি। অ্যাপ থেকে সিম নির্বাচন করুন।
+ শর্টকাট সরান
+ থিম পরিবর্তন করুন
+ GitHub খুলুন
+ Telegram খুলুন
+ বাহ্যিক অটোমেশন
+ Tasker, MacroDroid, Automate এবং বাহ্যিক স্ক্রিপ্টগুলিকে অ্যান্ড্রয়েড ব্রডকাস্ট ইন্টেন্টের মাধ্যমে নিরাপদে নেটওয়ার্ক মোড পরিবর্তনের অনুমতি দিন
+ প্রয়োগ করা হচ্ছে...
+ রুট প্রম্পটের বিলম্ব ছাড়াই নির্দিষ্ট নেটওয়ার্ক ব্যান্ড টগল করতে OEM রুটিন (Bixby, Google Assistant) বা ১-ট্যাপ হোম স্ক্রিন উইজেট ব্যবহার করুন
+ হোম ওয়াই-ফাইতে সংযুক্ত হলে, কর্মক্ষেত্রে প্রবেশ করলে বা নেভিগেশন শুরু করলে স্বয়ংক্রিয় সুইচিং ট্রিগার করুন
+ ৪ এর মধ্যে %1$d
+ টার্গেট মোড
+ শর্টকাট পরীক্ষা করুন
+ হোম স্ক্রিনে পিন করুন
+ সর্বোচ্চ ৪টি রুটিন শর্টকাট কনফিগার করা যাবে
+ শর্টকাট হোম স্ক্রিনে পিন করা হয়েছে!
+ আপনার লঞ্চার সরাসরি শর্টকাট পিন করা সমর্থন করে না
+ ব্রডকাস্ট
+ শর্টকাট
+ হোম
+ অটোমেশন
+ গাইড
+ PREF_4G
+ ৩ (উভয়)
+ PREF_3G
+ 2G
+ Extra 3 (স্ট্রিং):
+ Extra 2 (পূর্ণসংখ্যা):
+ token
+ নিরাপত্তা হ্যান্ডশেক
+ sim
+ ঐচ্ছিক
+ নিরাপত্তা টোকেন
+ ১ (সিম ১)
+ জেনারেট
+ ২ (সিম ২)
+ ●
+ শেল / ADB কমান্ড
+ কপি করতে ট্যাপ করুন
+ am broadcast -a com.dhangofa.networktoggle.SET_MODE -n com.dhangofa.networktoggle/.AutomationReceiver --es mode 5G --ei sim 1 --es token f0806bdd
+ ত্রুটি আইকন
+ ব্রডকাস্ট প্রোটোকল
+ প্যাকেজ:
+ ইন্টেন্ট প্যারামিটার এবং প্রিভিউ
+ com.dhangofa.networktoggle
+ ক্লাস:
+ স্পেসিফিকেশন কপি করুন
+ com.dhangofa.networktoggle.AutomationReceiver
+ অ্যাকশন:
+ Extra 1 (স্ট্রিং):
+ com.dhangofa.networktoggle.SET_MODE
+ mode
+ প্রিভিউ দেখতে ট্যাপ করুন
+ টার্গেট:
+ ব্রডকাস্ট (ইন্টেন্ট পাঠান)
+ 5G
+ 4G
+ PREF_5G
+ NetToggle
+ উন্নত নেটওয়ার্ক নিয়ন্ত্রণ
+ হোম
+ অটোমেশন
+ গাইড
+ #১
+ শর্টকাটের নাম
+ সর্বোচ্চ ৪টি কুইক অ্যাকশন শর্টকাট
+ ৪ এর মধ্যে ০
+ পরামর্শ
+ ● প্রস্তুত
+ ○ নিষ্ক্রিয়
+ ইন্টেন্ট রিসিভার:
+ AutomationReceiver
+ ব্রডকাস্ট পরীক্ষা করুন
+ আদর্শ অ্যান্ড্রয়েড ইন্টেন্ট স্পেসিফিকেশন
+ #%d
+ নোটিফিকেশন প্যানেল টানলে স্বয়ংক্রিয় পুনরুদ্ধার
+ অপারেটর, তাপমাত্রা বা সিস্টেম পরিবর্তনের কারণে নেটওয়ার্ক ডাউনগ্রেড হলে নোটিফিকেশন প্যানেল টানলে শেষ নির্বাচিত মোডটি পুনরায় প্রয়োগ করে
+ সুরক্ষা টোকেন খালি হতে পারে না
+ টোকেন কপি করুন
+ টোকেন ক্লিপবোর্ডে কপি করা হয়েছে
+ নতুন টোকেন তৈরি হয়েছে
+ ADB কমান্ড অনুলিপি করা হয়েছে
+ ব্রডকাস্ট স্পেসিফিকেশন অনুলিপি করা হয়েছে
+ ব্রডকাস্ট পাঠানো হয়েছে: %1$s (%2$s)
+ উভয়
+ SIM %1$d
+ ADB কমান্ড
+ ব্রডকাস্ট স্পেসিফিকেশন
+ অটোমেশন টোকেন
+ অ্যাকশন টাইপ
+ টার্গেট
+ প্যাকেজ
+ ক্লাস
+ অতিরিক্ত
+
+
+ রুট অনুমতি পরীক্ষা করা হচ্ছে…
+ Shizuku চলছে না
+ Shizuku মোড সক্রিয় & অনুমোদিত!
+ Shizuku অনুমতি দেওয়া হয়নি
+ Shizuku অনুমতির অনুরোধ করা হচ্ছে…
+ Shizuku পরীক্ষা ব্যর্থ হয়েছে
+
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
new file mode 100644
index 00000000..94aab749
--- /dev/null
+++ b/app/src/main/res/values-es/strings.xml
@@ -0,0 +1,205 @@
+
+
+ Desbloquear máximo rendimiento
+ NetToggle funciona más rápido si puede leer directamente el estado de su SIM. Otorgue el permiso de Teléfono para eliminar el retraso del sistema y habilitar la resolución instantánea.
+ Descartar
+ Otorgar permiso
+ Abrir ajustes
+ El permiso de teléfono se denegó permanentemente. Habilite el permiso de Teléfono en los Ajustes de la aplicación para desbloquear el máximo rendimiento.
+ Atajos de rutina
+ + Añadir atajo
+ Modo de ejecución
+ Autorizado
+ Configuración requerida
+ AUTORIZACIÓN PENDIENTE
+ Elija su modo de ejecución privilegiada
+ Se requieren permisos elevados para automatizar el cambio de red móvil desde Ajustes rápidos:
+ Shizuku
+ Recomendado
+ Depuración ADB inalámbrica • Sin desbloqueo de bootloader ni root
+ Servicio en ejecución • Listo para otorgar
+ Servicio inactivo • Inicie mediante la app Shizuku
+ Acceso Root
+ Superusuario
+ Privilegio directo mediante Magisk, KernelSU o APatch
+ No otorgado • Conceda privilegios en la app de gestión de root
+ Comprobando • Confirme la solicitud en la app de root...
+ Denegado / No encontrado • Otorgue privilegio en gestor root
+ Root otorgado y activo
+ Privilegio de root no otorgado. Proporcione acceso de superusuario en su app de root.
+ Modo root activo y autorizado
+ Permiso de root denegado o no disponible
+ Autorizar acceso de Shizuku
+ Otorgar privilegio de Root
+ ¿Necesita ayuda para configurar? Abrir pestaña Guías →
+ Bloqueado
+ Se requiere autorización para configurar la SIM objetivo. Conceda Root o Shizuku arriba.
+ Se requiere autorización para configurar el ciclo de Quick Tile. Conceda Root o Shizuku arriba.
+ Root
+ Shizuku
+ Ciclo rápido (Quick Tile)
+ 3/3
+ Seleccione de 2 a 3 modos. El orden de selección será el orden del ciclo.
+ Pref 5G
+ Pref 4G
+ Pref 3G
+ Solo 5G
+ Solo 4G
+ Solo 2G
+ Ciclo: Solo 5G → Solo 4G → Pref 5G
+ SIM objetivo
+ Auto falló
+ Auto
+ SIM 1
+ SIM 2
+ Ambas
+ Reporte de diagnóstico
+ CERRAR
+ COPIAR REPORTE
+ NetToggle
+ Dhangofa
+ Comando fallido
+ Toca para ver el reporte de diagnóstico
+ Autorice a Root o Shizuku para configurar los ajustes.
+ Autorice Root o Shizuku para habilitar la automatización externa
+ Autorice Root o Shizuku para probar el atajo
+ La automatización externa está deshabilitada. Habilítela para probar el broadcast
+ Se requieren ambas tarjetas SIM.
+ No se encontró tarjeta SIM en la ranura 1
+ No se encontró tarjeta SIM en la ranura 2
+ No se encontró tarjeta SIM en la ranura objetivo.
+ Reporte copiado al portapapeles
+ Falló la detección automática de SIM. Elija SIM 1 o SIM 2 manualmente.
+ LEER GUÍA
+ Modo de ejecución
+ Configurar Root o Shizuku
+ SIM objetivo y ciclo de Quick Tile
+ Administrar Dual SIM y ciclo de tile
+ Mosaico rápido (Quick Tile)
+ Añadir a Ajustes rápidos
+ Atajos de la app
+ Use rutinas OEM o atajos de 1 toque en pantalla de inicio
+ Transmisiones (Broadcasts)
+ Tasker, MacroDroid, Automate y scripts externos
+ Solución de problemas
+ Solución de problemas comunes y preguntas frecuentes
+ Guías
+ Seleccionado: %1$d/3
+ Seleccione el modo Root o Shizuku.
+ v%1$s
+ GPL V3
+ Su dispositivo o proveedor no soporta 5G.
+ 3G está deshabilitado o no es soportado por su proveedor.
+ 2G está deshabilitado o no es soportado por su proveedor.
+ Seleccione al menos 2 modos.
+ Puede seleccionar hasta 3 modos.
+ Ciclo:
+ Cambiando...
+ Shizuku no disponible
+ Root no disponible
+ Error (Revisar app)
+ Configuración requerida
+ Toca para aplicar %1$s
+ No se pudo detectar automáticamente la SIM de datos activa. Seleccione la SIM desde la aplicación.
+ Eliminar atajo
+ Cambiar tema
+ Abrir GitHub
+ Abrir Telegram
+ Automatización externa
+ Permita que Tasker, MacroDroid, Automate y scripts externos cambien los modos de red de forma segura mediante intents Broadcast de Android
+ Aplicando...
+ Use rutinas OEM (Bixby, Asistente de Google) o widgets de 1 toque en la pantalla de inicio para alternar bandas de red específicas sin retrasos de confirmación de root
+ Active el cambio automático al conectarse al Wi-Fi del hogar, llegar al trabajo o iniciar la navegación
+ %1$d de 4
+ Modo objetivo
+ Probar atajo
+ Fijar a inicio
+ Se pueden configurar un máximo de 4 atajos de rutina
+ ¡Atajo fijado a la pantalla de inicio!
+ Su launcher no admite fijar atajos directamente
+ Transmisiones
+ Atajos
+ Inicio
+ Automatizar
+ Guías
+ PREF_4G
+ 3 (Ambas)
+ PREF_3G
+ 2G
+ Extra 3 (Cadena):
+ Extra 2 (Entero):
+ token
+ apretón de manos de seguridad
+ sim
+ opcional
+ Token de seguridad
+ 1 (SIM 1)
+ GENERAR
+ 2 (SIM 2)
+ ●
+ COMANDO SHELL / ADB
+ tocar para copiar
+ am broadcast -a com.dhangofa.networktoggle.SET_MODE -n com.dhangofa.networktoggle/.AutomationReceiver --es mode 5G --ei sim 1 --es token f0806bdd
+ Icono de error
+ Protocolo de broadcast
+ Paquete:
+ Parámetros de intent y vista previa
+ com.dhangofa.networktoggle
+ Clase:
+ Copiar especificaciones
+ com.dhangofa.networktoggle.AutomationReceiver
+ Acción:
+ Extra 1 (Cadena):
+ com.dhangofa.networktoggle.SET_MODE
+ mode
+ tocar para vista previa
+ Destino:
+ Broadcast (Enviar Intent)
+ 5G
+ 4G
+ PREF_5G
+ NetToggle
+ Control de red avanzado
+ Inicio
+ Automatizar
+ Guías
+ #1
+ Nombre del atajo
+ Hasta 4 atajos de acción rápida
+ 0 de 4
+ Consejo
+ ● LISTO
+ ○ DESHABILITADO
+ Receptor de intent:
+ AutomationReceiver
+ Probar broadcast
+ Especificaciones estándar de Intent de Android
+ #%d
+ Restauración automática al desplegar panel
+ Vuelve a aplicar automáticamente el último modo seleccionado cuando se baja el panel si el operador o la temperatura lo degradaron o el sistema lo cambió
+ El token de seguridad no puede estar vacío
+ Copiar token
+ Token copiado al portapapeles
+ Nuevo token generado
+ Comando ADB copiado
+ Especificaciones de broadcast copiadas
+ Broadcast enviado: %1$s (%2$s)
+ Ambas
+ SIM %1$d
+ Comando ADB
+ Especificaciones de Broadcast
+ Token de automatización
+ Tipo de acción
+ Destino
+ Paquete
+ Clase
+ Extra
+
+
+ Comprobando permiso de root…
+ Shizuku no se está ejecutando
+ ¡Modo Shizuku activo & autorizado!
+ Permiso de Shizuku no concedido
+ Solicitando permiso de Shizuku…
+ Error al comprobar Shizuku
+
diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 00000000..417fad14
--- /dev/null
+++ b/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 907d3811..4189ee69 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,52 +1,138 @@
-
- #D0BCFF
- #23212A
- #EADDFF
+
+ #9CB071
+ #324021
+ #E6EDDF
-
- #000000
- #141218
- #2B2930
- #2B2930
+
+ #181715
+ #24221F
+ #36332F
- #E6E1E5
- #CAC4D0
- #938F96
+ #F0EFEA
+ #A39F98
+ #73706B
- #1A3822
- #3E1A19
- #F2B8B5
+ #23381B
+ #83B86E
+ #4A1D1A
+ #E07873
+ #4A3210
+ #E0AD60
+
+
+ #E07873
+ #E0AD60
+ #83B86E
- #2B2930
-
-
- #49454F
- #1B1A20
- #938F96
-
-
- #FFB782
- #F48FB1
-
-
- #11161A
- #1A130F
- #160309
- #2B2930
-
-
- #1A130F
- #3D210F
- #FFB782
-
-
- #57AFFF
- #11161A
- #1A364F
- #8ECCFF
+ #36332F
+ #24221F
+ #36332F
+
+
+ #181715
+ #2B2433
+ #252E1E
+ #36281A
+ #36332F
+ #B8A3CC
+ #9CB071
+ #DEBA90
+ #1D2A33
+ #331E23
+ #8BB4CC
+ #CC97A0
+ #282233
+ #B39DDB
+
+
+ #DEBA90
+ #24221F
+ #36332F
+ #DEBA90
+ #A39F98
+ #45413C
+ #181715
+
+
+ #B8A3CC
+ #24221F
+ #36332F
+ #B8A3CC
+ #A39F98
+ #45413C
+ #181715
+
+
+ #9CB071
+ #24221F
+ #36332F
+ #9CB071
+ #A39F98
+ #45413C
+ #181715
+
+
+ #483624
+ #324B27
+ #42532D
+ #36332F
+ #272522
+
+
+ #2D2336
+ #B39DDB
+ #25202D
+ #B8A3CC
+ #24221F
+ #36332F
+ #33302B
+ #3E2C15
+ #E0AD60
+ #23381B
+ #81C784
+ #33302B
+ #A39F98
+ #33302B
+ #A39F98
+ #F0EFEA
+ #181715
+ #B39DDB
+ #4D4944
+
+
+ #24221F
+ #33302B
+ #35322D
+ #F2EFE9
+ #736F68
+
+
+ #2E2C28
+ #1E1D1B
+ #FFFFFF
+ #8E8981
+
+ #98938B
+
+
+ #352945
+ #D0BCFF
+ #263B2A
+ #A6D3A9
+ #3D2E21
+ #F5BA82
+ #2D2638
+ #CEBAEE
+ @color/nav_pill_bg
+ @color/nav_pill_stroke
+ @color/nav_item_selected_content
+ #30FFFFFF
+ @color/nav_item_selected_bg_amoled
+ @color/nav_item_selected_bg_amoled
+ @color/nav_item_selected_content_amoled
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index d4d02947..88136b92 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,8 +1,19 @@
+
+
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..2a8a505a
--- /dev/null
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,205 @@
+
+
+ 解锁最佳性能
+ 如果能够直接读取您的 SIM 卡状态,NetToggle 的运行速度会更快。请授予“电话”权限以消除系统延迟并实现即时解析。
+ 忽略
+ 授予权限
+ 打开设置
+ 电话权限已被永久拒绝。请在应用设置中开启“电话”权限以解锁最高性能并消除延迟。
+ 日常快捷方式
+ + 添加快捷方式
+ 执行模式
+ 已授权
+ 需要配置
+ 等待授权
+ 选择您的特权执行模式
+ 通过快捷设置自动切换移动网络需要更高权限:
+ Shizuku
+ 推荐
+ 无线 ADB 调试 • 无需解锁引导加载程序或 Root
+ 服务正在运行 • 准备授权
+ 服务未运行 • 请通过 Shizuku 应用启动
+ Root 权限
+ 超级用户
+ 通过 Magisk、KernelSU 或 APatch 直接获取权限
+ 未授予 • 请在 Root 管理器中提供权限
+ 正在检查 • 请在 Root 管理器中确认提示...
+ 已拒绝 / 未找到 • 请在 Root 管理器中提供权限
+ 已授予 Root 且处于活动状态
+ 未授予 Root 权限。请在 Root 管理应用中提供超级用户访问权限。
+ Root 模式已激活并授权
+ Root 权限被拒绝或不可用
+ 授权 Shizuku 访问
+ 授予 Root 权限
+ 需要设置帮助?打开指南标签页 →
+ 已锁定
+ 配置目标 SIM 卡需要授权。请在上方授予 Root 或 Shizuku 权限。
+ 配置快捷图块轮播需要授权。请在上方授予 Root 或 Shizuku 权限。
+ Root
+ Shizuku
+ 快捷图块轮播 (Quick Tile)
+ 3/3
+ 选择 2 到 3 种模式。选择顺序将作为图块轮播顺序。
+ 首选 5G
+ 首选 4G
+ 首选 3G
+ 仅 5G
+ 仅 4G
+ 仅 2G
+ 轮播:仅 5G → 仅 4G → 首选 5G
+ 目标 SIM 卡
+ 自动失败
+ 自动
+ SIM 1
+ SIM 2
+ 双卡
+ 诊断报告
+ 关闭
+ 复制报告
+ NetToggle
+ Dhangofa
+ 命令执行失败
+ 点击查看诊断报告
+ 请授权 Root 或 Shizuku 以配置设置。
+ 请授权 Root 或 Shizuku 以启用外部自动化
+ 请授权 Root 或 Shizuku 以测试快捷方式
+ 外部自动化已禁用。请启用它以测试广播
+ 需要两张 SIM 卡。
+ 卡槽 1 未检测到 SIM 卡
+ 卡槽 2 未检测到 SIM 卡
+ 目标卡槽未检测到 SIM 卡。
+ 报告已复制到剪贴板
+ 自动检测 SIM 卡失败。请手动选择 SIM 1 或 SIM 2。
+ 阅读指南
+ 执行模式
+ 配置 Root 或 Shizuku
+ 目标 SIM 卡与快捷图块轮播
+ 管理双卡与图块轮播
+ 快捷图块 (Quick Tile)
+ 添加到快捷设置
+ 应用快捷方式
+ 使用 OEM 日常程序或 1 键主屏幕快捷方式
+ 广播 (Broadcasts)
+ Tasker、MacroDroid、Automate 及外部脚本
+ 问题排查
+ 解决常见问题与常见疑问
+ 指南
+ 已选:%1$d/3
+ 请选择 Root 或 Shizuku 模式。
+ v%1$s
+ GPL V3
+ 您的设备或当前运营商不支持 5G。
+ 3G 已停用或不受当前运营商支持。
+ 2G 已停用或不受当前运营商支持。
+ 请至少选择 2 种模式。
+ 最多可选择 3 种模式。
+ 轮播:
+ 切换中...
+ Shizuku 不可用
+ Root 不可用
+ 错误(请检查应用)
+ 需要配置
+ 点击切换为 %1$s
+ 无法自动检测活动的移动数据 SIM 卡。请在应用中选择 SIM 卡。
+ 移除快捷方式
+ 更改主题
+ 打开 GitHub
+ 打开 Telegram
+ 外部自动化
+ 允许 Tasker、MacroDroid、Automate 和外部脚本通过 Android 广播 Intent 安全切换网络模式
+ 正在应用...
+ 使用 OEM 日常程序(Bixby、Google 助理)或 1 键主屏幕微件切换特定网络频段,无需等待 Root 提示
+ 在连接到家庭 Wi-Fi、进入工作场所或开始导航时触发自动切换
+ %1$d / 4
+ 目标模式
+ 测试快捷方式
+ 添加到主屏幕
+ 最多可配置 4 个日常快捷方式
+ 快捷方式已添加到主屏幕!
+ 您的桌面不支持直接添加快捷方式
+ 广播
+ 快捷方式
+ 首页
+ 自动化
+ 指南
+ PREF_4G
+ 3 (双卡)
+ PREF_3G
+ 2G
+ Extra 3 (字符串):
+ Extra 2 (整数):
+ token
+ 安全握手
+ sim
+ 可选
+ 安全令牌
+ 1 (SIM 1)
+ 生成
+ 2 (SIM 2)
+ ●
+ SHELL / ADB 命令
+ 点击复制
+ am broadcast -a com.dhangofa.networktoggle.SET_MODE -n com.dhangofa.networktoggle/.AutomationReceiver --es mode 5G --ei sim 1 --es token f0806bdd
+ 错误图标
+ 广播协议
+ 应用包名:
+ Intent 参数与预览
+ com.dhangofa.networktoggle
+ 类名:
+ 复制规范
+ com.dhangofa.networktoggle.AutomationReceiver
+ 动作 (Action):
+ Extra 1 (字符串):
+ com.dhangofa.networktoggle.SET_MODE
+ mode
+ 点击预览
+ 目标:
+ 广播 (发送 Intent)
+ 5G
+ 4G
+ PREF_5G
+ NetToggle
+ 高级网络控制
+ 首页
+ 自动化
+ 指南
+ #1
+ 快捷方式名称
+ 最多 4 个快速操作快捷方式
+ 0 / 4
+ 提示
+ ● 就绪
+ ○ 已禁用
+ Intent 接收器:
+ AutomationReceiver
+ 测试广播
+ 标准 Android Intent 规范
+ #%d
+ 下拉通知栏时自动恢复
+ 当因运营商、发热或系统更改导致网络降级时,下拉通知栏会自动重新应用您上次切换的模式
+ 安全令牌不能为空
+ 复制令牌
+ 令牌已复制到剪贴板
+ 已生成新令牌
+ ADB 命令已复制
+ 广播规格已复制
+ 广播已发送:%1$s (%2$s)
+ 两张
+ SIM %1$d
+ ADB 命令
+ 广播规格
+ 自动化令牌
+ 操作类型
+ 目标
+ 包名
+ 类名
+ 附加参数
+
+
+ 正在检查 Root 权限…
+ Shizuku 未运行
+ Shizuku 模式已激活 & 获授权!
+ 未授予 Shizuku 权限
+ 正在请求 Shizuku 权限…
+ Shizuku 检查失败
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index fd301231..a6254f97 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,52 +1,138 @@
-
- #6750A4
- #F3EDF7
- #21005D
+
+ #6B8E4E
+ #E6EDDF
+ #242E18
-
- #FDFBFF
+
+ #FFFCF7
#FFFFFF
- #E7E0EC
- #F3EDF7
+ #EAE5DC
- #1C1B1F
- #49454F
- #79747E
+ #2D2A26
+ #605B56
+ #948F88
- #E8F5E9
- #FFEDEA
- #8C1D18
+ #EDF4E9
+ #4A6B3E
+ #FBECEB
+ #AD3B36
+ #FDF4E6
+ #B37722
+
+
+ #E53935
+ #FB8C00
+ #43A047
- #F4EFF4
-
-
- #CAC4D0
- #F3EDF7
- #49454F
-
-
- #D96509
- #C2185B
-
-
- #F2F8FF
- #FFF8F3
- #FCE9F1
- #FFFFFF
-
-
- #FFF8F3
- #FFE0C2
- #7A3600
-
-
- #005B9F
- #F2F8FF
- #D6EBFF
- #004275
+ #F4EFE6
+ #FFFFFF
+ #F4EFE6
+
+
+ #FFFFFF
+ #F4EEFA
+ #EEF4EA
+ #FFF8ED
+ #FFFFFF
+ #9B72CF
+ #6B8E4E
+ #D98A44
+ #EEF2F5
+ #F5EEF0
+ #4A7A96
+ #A65E6B
+ #F2EFF8
+ #7E57C2
+
+
+ #D98A44
+ #FFFFFF
+ #FFFFFF
+ #D98A44
+ #7A746D
+ #EAE5DC
+ #F7F3EB
+
+
+ #9B72CF
+ #FFFFFF
+ #FFFFFF
+ #9B72CF
+ #7A746D
+ #EAE5DC
+ #F7F3EB
+
+
+ #6B8E4E
+ #FFFFFF
+ #FFFFFF
+ #6B8E4E
+ #7A746D
+ #EAE5DC
+ #F7F3EB
+
+
+ #F2E7D5
+ #DCE7D6
+ #D6E0CE
+ #EAE5DC
+ #EFE9DF
+
+
+ #F3EDF7
+ #7E57C2
+ #FAF7FD
+ #9B72CF
+ #FFFFFF
+ #EAE5DC
+ #F4EFEA
+ #FEF3E2
+ #B57400
+ #EBF5E9
+ #388E3C
+ #F0ECE5
+ #605B56
+ #F0ECE5
+ #7A746D
+ #262422
+ #FFFFFF
+ #7E57C2
+ #D5CECA
+
+
+ #FFFFFF
+ #EAE5DC
+ #F3EFE8
+ #262422
+ #98938B
+
+
+ #2E2C28
+ #1E1D1B
+ #FFFFFF
+ #8E8981
+
+ #6E6B65
+
+
+ #F2ECFB
+ #6A3EA1
+ #EAF5EC
+ #3B6E44
+ #FDF2E6
+ #B56B24
+ #F3EEFA
+ #5E4780
+ @color/nav_pill_bg
+ @color/nav_pill_stroke
+ @color/nav_item_selected_content
+ #30262422
+ @color/nav_item_selected_bg_amoled
+ @color/nav_item_selected_bg_amoled
+ @color/nav_item_selected_content_amoled
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..98157149
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,208 @@
+
+
+ Unlock Maximum Performance
+ NetToggle operates faster if it can directly read your SIM state. Please grant the Phone permission to eliminate system lag and enable instant resolution.
+ Dismiss
+ Grant Permission
+ Open Settings
+ Phone permission was permanently denied. Please enable Phone permission in App Settings to unlock maximum performance and eliminate lag.
+ Routine Shortcuts
+ + Add Shortcut
+ Execution Mode
+ Authorized
+ Setup Required
+ PENDING AUTH
+ Choose your privileged execution mode
+ Elevated permissions are required to automate Quick Settings mobile network switching:
+ Shizuku
+ Recommended
+ Wireless ADB debugging • No bootloader unlock or root required
+ Service running • Ready to grant
+ Service not running • Start via Shizuku app
+ Root Access
+ Superuser
+ Direct privilege via Magisk, KernelSU or APatch
+ Not granted • Provide privilege in root manager app
+ Checking • Confirm prompt in root manager app...
+ Denied / Not found • Provide privilege in root manager
+ Root granted & active
+ Root privilege not granted. Please provide superuser access in your root manager app.
+ Root mode active & authorized
+ Root permission denied or unavailable
+ Authorize Shizuku Access
+ Grant Root Privilege
+ Need help setting up? Open Guides tab →
+ Locked
+ Authorization required to configure Target SIM. Please grant Root or Shizuku above.
+ Authorization required to configure Quick Tile Cycle. Please grant Root or Shizuku above.
+ Root
+ Shizuku
+ Quick Tile Cycle
+ 3/3
+ Select 2 to 3 modes. Selection order becomes the tile cycle order.
+ Pref 5G
+ Pref 4G
+ Pref 3G
+ 5G Only
+ 4G Only
+ 2G Only
+ Cycle: 5G Only → 4G Only → Preferred 5G
+ Target SIM
+ Auto failed
+ Auto
+ SIM 1
+ SIM 2
+ Both
+ Diagnostic Report
+ CLOSE
+ COPY REPORT
+ NetToggle
+ Dhangofa
+ Command Failed
+ Tap to view diagnostic report
+ Please authorize Root or Shizuku to configure toggles
+ Please authorize Root or Shizuku to enable External Automation
+ Please authorize Root or Shizuku to test shortcut
+ External Automation is disabled. Enable it to test broadcast
+ Both SIM cards are required
+ No SIM card found in slot 1
+ No SIM card found in slot 2
+ No SIM card found in target slot
+ Report copied to clipboard
+ Auto SIM detection failed. Please choose SIM 1 or SIM 2 manually
+ READ GUIDE
+ Execution Mode
+ Configure Root or Shizuku
+ Target SIM & Quick Tile Cycle
+ Handle Dual SIM & tile cycle
+ Quick Tile
+ Add to Quick Settings
+ App Shortcuts
+ Use OEM Routines or 1-tap Home Screen Shortcuts
+ Broadcasts
+ Tasker, MacroDroid, Automate, and external scripts
+ Troubleshooting
+ Fix common issues & FAQ
+ Guides
+ Selected: %1$d/3
+ Select Root or Shizuku mode
+ v%1$s
+ GPL V3
+ 5G is not supported by your device or current carrier
+ 3G is disabled or not supported by your current carrier
+ 2G is disabled or not supported by your current carrier
+ Select at least 2 tile modes
+ You can select up to 3 tile modes
+ Cycle:
+ Switching...
+ Shizuku Unavailable
+ Root Unavailable
+ Error Check App
+ Setup Required
+ Tap to Set %1$s
+ Unable to detect active data SIM automatically. Please choose SIM from the app
+
+ Remove shortcut
+ Change theme
+ Open GitHub
+ Open Telegram
+ External Automation
+ Allow Tasker, MacroDroid, Automate, and external scripts to change network modes securely via Android Broadcast intents
+ Applying...
+ Use OEM Routines (Bixby, Google Assistant) or 1-tap Home Screen widgets to toggle specific network bands without root prompt delays
+ Trigger automatic switching when connecting to Home Wi-Fi, entering work locations, or starting navigation
+ %1$d of 4
+ Target Mode
+ Test Shortcut
+ Pin to Home
+ Maximum 4 routine shortcuts can be configured
+ Shortcut pinned to Home screen!
+ Your launcher does not support direct pin shortcuts
+ Broadcasts
+ Shortcuts
+ Home
+ Automate
+ Guides
+ PREF_4G
+ 3 (Both)
+ PREF_3G
+ 2G
+ Extra 3 (String):
+ Extra 2 (Integer):
+ token
+ security handshake
+ sim
+ optional
+ Security Token
+ 1 (SIM 1)
+ GENERATE
+ 2 (SIM 2)
+ ●
+ SHELL / ADB COMMAND
+ tap to copy
+ am broadcast -a com.dhangofa.networktoggle.SET_MODE -n com.dhangofa.networktoggle/.AutomationReceiver --es mode 5G --ei sim 1 --es token f0806bdd
+ Error Icon
+ Broadcast Protocol
+ Package:
+ Intent parameters & preview
+ com.dhangofa.networktoggle
+ Class:
+ Copy Specs
+ com.dhangofa.networktoggle.AutomationReceiver
+ Action:
+ Extra 1 (String):
+ com.dhangofa.networktoggle.SET_MODE
+ mode
+ tap to preview
+ Target:
+ Broadcast (Send Intent)
+ 5G
+ 4G
+ PREF_5G
+ NetToggle
+ Advanced Network Control
+ Home
+ Automate
+ Guides
+ #1
+ Shortcut Name
+ Up to 4 quick action shortcuts
+ 0 of 4
+ Tip
+ ● READY
+ ○ DISABLED
+ Intent Receiver:
+ AutomationReceiver
+ Test Broadcast
+ Standard Android Intent specifications
+ #%d
+
+
+ Auto-Restore on shade pull
+ Automatically re-applies your last toggled mode when shade is pulled if carrier or thermals downgraded or the system altered it
+ Security token cannot be empty
+ Copy Token
+ Token copied to clipboard
+ New token generated
+ ADB command copied
+ Broadcast specifications copied
+ Broadcast sent: %1$s (%2$s)
+ Both
+ SIM %1$d
+ ADB Command
+ Broadcast Specifications
+ Automation Token
+ Action Type
+ Target
+ Package
+ Class
+ Extra
+
+
+ Checking root permission…
+ Shizuku is not running
+ Shizuku mode active & authorized!
+ Shizuku permission not granted
+ Requesting Shizuku permission…
+ Shizuku check failed
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 51ba620f..df89aa69 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,8 +1,19 @@
+
+
+
+
diff --git a/fastlane/metadata/android/en-US/changelogs/34.txt b/fastlane/metadata/android/en-US/changelogs/34.txt
new file mode 100644
index 00000000..781fd139
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/34.txt
@@ -0,0 +1,7 @@
+- Completely redesigned NetToggle with fluid animations and pill navigation across Home, Automate, and Guide
+- Added secure Tasker, MacroDroid, and Automate broadcasts
+- Added up to 4 customizable routine and launcher shortcuts
+- Added Both-SIM targeting, capability intersection along with Auto, SIM 1, SIM 2
+- Added Auto Restore and SIM badges for the QS tile
+- Improved Shizuku lifecycle handling and diagnostics
+- Added Bengali, Chinese, Portuguese, Russian, and Spanish translations
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 4ff187b8..9152ee3e 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,37 +1,49 @@
-NetToggle is a lightweight Android Quick Settings (QS) tile app for switching cellular network modes directly from Quick Settings. Many phones and carrier configurations hide or limit advanced network mode options, especially strict 5G/4G switching. NetToggle uses privileged shell access through Root or Shizuku to run Android telephony commands and apply selected network modes where supported by the device, ROM, modem, and carrier.
-
-⚙️ NETWORK MODES
-5G Only Attempts to keep the selected SIM on 5G-only mode where supported.
-4G Only : Attempts to keep the selected SIM on 4G/LTE-only mode where supported.
-Preferred 5G : Sets 5G as the preferred network type with fallback behavior controlled by the device and carrier.
-Preferred 4G : Sets 4G/LTE as the preferred network type with fallback behavior controlled by the device and carrier.
-Preferred 3G : Sets 3G (WCDMA/UMTS) as the preferred network type for legacy connectivity.
-2G Only : Locks the device to 2G (GSM) for maximum battery savings where legacy networks still exist.
-
-🔄 QUICK TILE CYCLE
-Configurable Sequence : Completely customize your QS tile behavior. Pick a cycle of 2 or 3 of your most-used network modes.
-Dynamic Filtering : NetToggle's smart capability resolver automatically evaluates your hardware and global carrier configurations. It dynamically hides 2G or 3G options if your carrier has sunset those legacy networks (utilizing a built-in global LTE-and-above registry covering the USA, Japan, Australia, Taiwan, Singapore, and South Korea).
-
-📱 TARGET SIM
-Auto : Uses native API fast-paths to instantly resolve your active data subscription and map it to the correct physical SIM slot.
-SIM 1 & SIM 2 : Manually bypass auto-detection and apply network mode changes explicitly to physical slot 1 or 2.
-
-🔌 REQUIREMENTS
-Root Mode : Uses su access from solutions such as Magisk, APatch, or KernelSU.
-Shizuku Mode : Uses Shizuku's ADB shell permission path without requiring full system root, complete with passive background binder checks.
-
-🛠️ DESIGN & FEATURES>
-Modern UI Overhaul : A lightweight, single-screen interface built with a native morphing carousel, edge-to-edge layout, adaptive 3D icons (API 26+), and pill-shaped segmented controls. No heavy bloat.
-Interactive Setup Guide : A visually polished onboarding carousel guides you through Execution Modes, Target SIMs & Tile Cycle, and QS tile setup.
-Deep Diagnostic Engine : Instantly troubleshoot failures. NetToggle now captures deep telemetry (exit codes, stdout, stderr, exceptions) when a Root or Shizuku command fails, viewable in a copyable diagnostic dialog.
-Lightweight Operation : No background polling and no continuous service.
-
+NetToggle is a lightweight Android Quick Settings (QS) tile app for cellular network control. Create custom tile cycles, target one or both SIMs, build shortcuts, secure broadcast automation, and troubleshoot failures with detailed diagnostics.
+⚙️ NETWORK MODES
+
+Preferred 5G (5G/4G/3G/2G) : Recommended for daily use. Allows 5G SA and NSA, including UW, UC, 5G+, or 5G++ where supported, with LTE and legacy fallback.
+5G Only (NR Only) : Disables 4G, 3G, and 2G. Intended for 5G SA networks. Service and calls may fail on NSA networks or without SA and VoNR.
+Preferred 4G (4G/3G/2G) : Disables 5G while retaining LTE and legacy fallback. Useful for battery saving, reduced heat, or unstable 5G.
+4G Only (LTE/LTE-A) : Locks to LTE without 3G/2G fallback. Calls require VoLTE; carrier aggregation is controlled by the modem and network.
+Preferred 3G (3G/2G) : Disables 4G and 5G while allowing supported 3G and 2G networks.
+2G Only (GSM/EDGE) : Locks to 2G where legacy service remains available.
+
+If calls or service fail in an Only mode, return to Preferred 5G or Preferred 4G.
+🔄 QUICK TILE CYCLE
+
+Custom Sequence : Choose an ordered cycle of 2 or 3 modes.
+Live State : Shows the active mode with an Auto, SIM 1, SIM 2, or Both badge.
+Auto Restore : Optionally restores the last selected mode if Android, thermal management, or the carrier changes it.
+Efficient Refresh : Controlled checks avoid continuous polling.
+
+📱 TARGET SIM
+
+Auto : Resolves the active data subscription and physical SIM slot.
+SIM 1 or SIM 2 : Applies changes to the selected SIM.
+Both SIMs : Offers mutually supported modes, applies changes to both, and reports partial failures.
+
+If Auto detection fails, select a SIM manually.
+🪄 SHORTCUTS & ROUTINES
+Create up to 4 capability-aware actions with custom names, modes, and SIM targets. Use app shortcuts, pin actions, or integrate with supported OEM routines.
+📡 BROADCAST AUTOMATION
+Integrate with Tasker, MacroDroid, Automate, and similar tools. External automation is disabled by default and requires opt-in.
+🔌 REQUIREMENTS & PERMISSIONS
+
+Root or Shizuku Required : Either must be active and authorized. Shizuku is recommended and can run through Root, ADB, or Wireless Debugging. Trusted forks may add start on boot, watchdog, or TCP mode. Root supports Magisk, KernelSU, APatch, and compatible su solutions.
+Read Phone State : Optional permission for faster native SIM and capability detection. Without it, privileged shell fallbacks are used where available.
+Run at Startup : Briefly clears stale tile state after boot or app update. No persistent background service is used.
+
+Some root managers require manual authorization. Shizuku users must authorize NetToggle.
+🧠 SMART CAPABILITY FILTERING
+Evaluates device, SIM, and carrier information to hide unsupported modes. Both-SIM operation offers mutually supported modes. A registry helps hide legacy options on known 2G/3G sunset networks.
+🎨 DESIGN & THEMES
+Portrait and landscape layouts include guided setup and Auto, Light, Dark, and AMOLED themes.
+🛠️ DIAGNOSTICS
+Copyable reports include commands, exit codes, output, exceptions, execution state, and SIM resolution.
💻 COMPATIBILITY
-Seamless Legacy Support (Android 7-11) : Bypasses missing framework commands on older devices by utilizing a standalone, headless Java reflection payload executed natively via app_process.
-Broad Android Support : Fully supports Android 7.0 (API 24) through Android 16 (API 36).
-
-Network mode support may vary depending on Android version, OEM skin, ROM, modem, SIM setup, and carrier restrictions.
-
-🛡️ PRIVACY
-No Trackers : No ads, analytics, telemetry, or tracking SDKs.
-No Internet Permission : NetToggle works completely offline. It does not collect, store, or transmit user data.
+Supports Android 7.0 and newer. Availability varies by Android version, OEM, ROM, modem, SIM, local network, and carrier.
+🛡️ PRIVACY
+
+No advertisements, analytics, telemetry, or trackers.
+No Internet permission or transmission of user data.
+
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index 6b5ad306..0db5a58c 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
deleted file mode 100644
index 6aadfcc1..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
deleted file mode 100644
index f2c5478c..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
deleted file mode 100644
index eb281079..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
deleted file mode 100644
index 7f06934e..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
deleted file mode 100644
index c60a7414..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
deleted file mode 100644
index 90926af5..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
deleted file mode 100644
index 55f4fb21..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png
deleted file mode 100644
index 4153590f..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 1.png
new file mode 100644
index 00000000..77f5435e
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 1.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 2.png
new file mode 100644
index 00000000..2b1b26f7
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 2.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 3.png
new file mode 100644
index 00000000..b384aa65
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 3.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 4.png
new file mode 100644
index 00000000..63746920
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 4.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 5.png
new file mode 100644
index 00000000..26ce712f
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 5.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 6.png
new file mode 100644
index 00000000..082f0b0c
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 6.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 7.png
new file mode 100644
index 00000000..4ec88085
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 7.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 8.png
new file mode 100644
index 00000000..d761f31b
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/Frame 8.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 45b8ca6c..77abefcd 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Force 5G/4G Only or Pref modes directly from Quick Settings using Root/Shizuku
+5G network control with custom Quick Settings cycles, shortcuts and automation
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index e6441136..a4b76b95 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 381baa9c..d7104778 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionSha256Sum=8d97a97984f6cbd2b85fe4c60a743440a347544bf18818048e611f5288d46c94
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 1aa94a42..f3b75f3b 100755
--- a/gradlew
+++ b/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/gradlew.bat b/gradlew.bat
index 7101f8e4..9b42019c 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
diff --git a/screenshot/4g.png b/screenshot/4g.png
index cfa5e9a9..b384aa65 100644
Binary files a/screenshot/4g.png and b/screenshot/4g.png differ
diff --git a/screenshot/5g.png b/screenshot/5g.png
index deb7fa17..2b1b26f7 100644
Binary files a/screenshot/5g.png and b/screenshot/5g.png differ
diff --git a/screenshot/ic_launcher.png b/screenshot/ic_launcher.png
index 6eda3941..0db5a58c 100644
Binary files a/screenshot/ic_launcher.png and b/screenshot/ic_launcher.png differ
diff --git a/screenshot/main_new.png b/screenshot/main_new.png
index 502332ba..77f5435e 100644
Binary files a/screenshot/main_new.png and b/screenshot/main_new.png differ
diff --git a/screenshot/p4g.png b/screenshot/p4g.png
index 4bea44ce..26ce712f 100644
Binary files a/screenshot/p4g.png and b/screenshot/p4g.png differ
diff --git a/screenshot/p5g.png b/screenshot/p5g.png
index ad42567d..63746920 100644
Binary files a/screenshot/p5g.png and b/screenshot/p5g.png differ