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 - - - - - + + + + + - - - - + + + +
1234
Multi ThemeSetup CardTarget SIM SetupQuick Tile Cycle Setup
5678Routine ShortcutsBroadcast AutomationGuides & DocumentationTile In Action
-## 📱 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">