diff --git a/.github/ISSUE_TEMPLATE/01-bug-report-android.yml b/.github/ISSUE_TEMPLATE/01-bug-report-android.yml deleted file mode 100644 index 5b8d6a264..000000000 --- a/.github/ISSUE_TEMPLATE/01-bug-report-android.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Bug report (Android) -description: Report a bug in the Android app -labels: ["bug", "android"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to report a bug. Please fill in as much as you can. - - type: input - id: app-version - attributes: - label: App version - description: "Find this in `Settings → About → Version` in the app, or in your phone's app info." - placeholder: "v0.2.5 (build 46)" - validations: - required: true - - type: dropdown - id: app-source - attributes: - label: App source - options: - - GitHub - - Play - - Built from source - validations: - required: true - - type: input - id: device - attributes: - label: Device - description: Manufacturer and model. - placeholder: "Google Pixel 8 Pro" - validations: - required: true - - type: input - id: android-version - attributes: - label: Android / OS version - description: Include the OEM skin if relevant. - placeholder: "Android 16, OxygenOS 16, ColorOS 16, ..." - validations: - required: true - - type: dropdown - id: root-method - attributes: - label: Root / hook method - options: - - No root (native L2CAP support) - - Magisk + Xposed - - KernelSU + Xposed - - Other (describe in additional context) - validations: - required: true - - type: dropdown - id: airpods-model - attributes: - label: AirPods model - options: - - AirPods (1st gen) - - AirPods (2nd gen) - - AirPods (3rd gen) - - AirPods (4th gen) - - AirPods (4th gen) with ANC - - AirPods Pro (1st gen) - - AirPods Pro 2 (Lightning) - - AirPods Pro 2 (USB-C) - - AirPods Pro 3 - - Other / not sure - validations: - required: true - - type: input - id: firmware - attributes: - label: AirPods firmware - description: Find this under `About` in the app once connected. - placeholder: "8454768" - - type: textarea - id: description - attributes: - label: What happened - description: Describe what you observed and what you expected. Include steps to reproduce if applicable. - validations: - required: true - - type: textarea - id: logs - attributes: - label: Logs - description: | - If you are rooted, give the app root access, open the app, go to `Settings → Troubleshooting → Collect Logs`, and attach the resulting file here. - - Without logs most bugs are very hard to diagnose. If you are not, follow these instructions: - (Needs access to a computer, and USB/Wireless Debugging under developer options enabled) - - Commands: - - Get the uid: Linux/Mac: `adb shell dumpsys package me.kavishdevar.librepods | grep uid` - - Start logs: `adb logcat --uid=,1002 >> librepods-logs.txt` (1002 is for bluetooth) - - Steps for proper logs - - force close the app - - turn off bluetooth - - start logs - - open the app - - turn on bluetooth and connect - - placeholder: Paste log content or attach the file - - type: textarea - id: extra - attributes: - label: Additional context - description: Anything else that might help (screenshots, video, related issues, what you've already tried). diff --git a/.github/ISSUE_TEMPLATE/02-bug-report-linux.yml b/.github/ISSUE_TEMPLATE/02-bug-report-linux.yml deleted file mode 100644 index 46aa9a4dd..000000000 --- a/.github/ISSUE_TEMPLATE/02-bug-report-linux.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Bug report (Linux) -description: Report a bug in the Linux program -labels: ["bug", "linux"] -title: "[Linux] " -body: - - type: markdown - attributes: - value: | - Thanks for the report. Please fill in as much as you can. - - type: input - id: app-version - attributes: - label: App version - placeholder: "linux-v0.1.0, or linux-rust commit abc1234" - validations: - required: true - - type: dropdown - id: variant - attributes: - label: Variant - options: - - Rust rewrite (`linux-rust` branch) - - QT version (NOT MAINTAINED! issues will be closed) - validations: - required: true - - type: input - id: distro - attributes: - label: Distro and version - placeholder: "Arch Linux, Fedora 41, Ubuntu 24.04, NixOS 25.05" - validations: - required: true - - type: input - id: desktop - attributes: - label: Desktop environment / compositor - placeholder: "GNOME 47 (Wayland), KDE 6 (X11), Hyprland, ..." - validations: - required: true - - type: dropdown - id: install-method - attributes: - label: Install method (only official sources) - options: - - Built from source (`nix` or otherwise) - - Pre-built binary - - AppImage - - Other - validations: - required: true - - type: dropdown - id: airpods-model - attributes: - label: AirPods model - options: - - AirPods (1st gen) - - AirPods (2nd gen) - - AirPods (3rd gen) - - AirPods (4th gen) - - AirPods (4th gen) with ANC - - AirPods Pro (1st gen) - - AirPods Pro 2 (Lightning) - - AirPods Pro 2 (USB-C) - - AirPods Pro 3 - - Other / not sure - validations: - required: true - - type: textarea - id: description - attributes: - label: What happened - description: Describe what you observed and what you expected. Include steps to reproduce if applicable. - validations: - required: true - - type: textarea - id: logs - attributes: - label: Logs and stderr - description: Run the app from a terminal with `--debug` and paste the output. - - type: textarea - id: extra - attributes: - label: Additional context diff --git a/.github/ISSUE_TEMPLATE/03-feature-request.yml b/.github/ISSUE_TEMPLATE/03-feature-request.yml deleted file mode 100644 index 682e63839..000000000 --- a/.github/ISSUE_TEMPLATE/03-feature-request.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Feature request -description: Suggest a new feature or improvement -labels: ["enhancement"] -body: - - type: dropdown - id: scope - attributes: - label: Scope - options: - - Android - - Linux - - Both - - Other - validations: - required: true - - type: textarea - id: problem - attributes: - label: Problem or use case - description: What are you trying to do? What is missing or hard today? - validations: - required: true - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: How might it work? UI sketches, behavior, edge cases. - - type: textarea - id: alternatives - attributes: - label: Alternatives considered diff --git a/.github/workflows/ci-linux-rust.yml b/.github/workflows/ci-linux-rust.yml deleted file mode 100644 index 1753132fe..000000000 --- a/.github/workflows/ci-linux-rust.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Linux CI (Rust) - -on: - push: - branches: - - linux/rust - tags: - - 'linux-v*' - paths: - - 'linux-rust/**' - - '.github/workflows/linux-build.yml' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2 - - - name: Install AppImage tools - run: | - wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool - wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy - chmod +x /usr/local/bin/{appimagetool,linuxdeploy} - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - linux-rust/target - key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }} - - - name: Build AppImage and Binary - working-directory: linux-rust - run: | - cargo build --release --verbose - just - mkdir -p dist - cp target/release/librepods dist/librepods - mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage - - - name: Upload AppImage artifact - if: "!startsWith(github.ref, 'refs/tags/linux-v')" - uses: actions/upload-artifact@v4 - with: - name: librepods-x86_64.AppImage - path: linux-rust/dist/librepods-x86_64.AppImage - - - name: Upload binary artifact - if: "!startsWith(github.ref, 'refs/tags/linux-v')" - uses: actions/upload-artifact@v4 - with: - name: librepods - path: linux-rust/dist/librepods - - - name: Create tarball for Flatpak - if: startsWith(github.ref, 'refs/tags/linux-v') - working-directory: linux-rust - run: | - VERSION="${GITHUB_REF_NAME#linux-v}" - just tarball "${VERSION}" - echo "VERSION=${VERSION}" >> $GITHUB_ENV - echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV - - - name: Create GitHub Release (AppImage + binary + source) - if: startsWith(github.ref, 'refs/tags/linux-v') - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - files: | - linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz - linux-rust/dist/librepods-x86_64.AppImage - linux-rust/dist/librepods - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml deleted file mode 100644 index a26b3ba1d..000000000 --- a/.github/workflows/ci-linux.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build LibrePods Linux - -on: - workflow_dispatch: -# push: -# branches: -# - '*' - -jobs: - build-linux: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - qt6-base-dev qt6-declarative-dev qt6-svg-dev \ - qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \ - libxkbcommon-dev - - - name: Build project - working-directory: linux - run: | - mkdir build - cd build - cmake .. -G Ninja - ninja - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: librepods-linux - path: linux/build/librepods diff --git a/.github/workflows/wear-build.yml b/.github/workflows/wear-build.yml new file mode 100644 index 000000000..c73a6530b --- /dev/null +++ b/.github/workflows/wear-build.yml @@ -0,0 +1,29 @@ +name: Wear OS build + +on: + push: + branches: + - wearos/initial-cleanup + pull_request: + branches: + - wearos/initial-cleanup + +jobs: + debug-build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Build Wear debug APK + run: ./gradlew :app:assembleDebug --stacktrace diff --git a/AI_ROADMAP_2026-08-20.md b/AI_ROADMAP_2026-08-20.md new file mode 100644 index 000000000..7eed6c26c --- /dev/null +++ b/AI_ROADMAP_2026-08-20.md @@ -0,0 +1,76 @@ +# LibrePods-Wear — AI Roadmap 2026-08-20 + +## Goal + +Build a fully autonomous Wear OS LibrePods client. The watch owns the AirPods connection, AACP/ATT protocol stack, BLE status processing, state model, and UI. No phone-side LibrePods service is required for normal operation. + +## Current architecture + +- `AirPodsController` — Wear-facing orchestration and state lifecycle. +- `AirPodsState` / `AirPodsStateStore` — single observable device state. +- `WearBluetoothConnection` — Wear transport facade. +- `AirPodsConnectionSession` — Classic Bluetooth L2CAP/ATT session ownership. +- `AACPManager` — protocol packet engine. +- `BLEManager` — passive AirPods BLE status monitoring. + +## Phase 1 — Build and connection foundation + +- [x] Remove obsolete phone-only architecture from the Wear path. +- [x] Make AACP transport explicitly Wear-owned. +- [x] Restore a clean debug build. +- [x] Connect to a bonded AirPods device from Wear OS. +- [ ] Implement the complete AACP handshake sequence: handshake ACK -> feature flags -> notification subscription. +- [ ] Add robust AACP stream/frame handling. +- [ ] Add connection timeout and deterministic disconnect/reconnect handling. +- [ ] Add protocol logging suitable for real-device debugging. + +## Phase 2 — Device status + +- [ ] Parse AACP battery packets for Left / Right / Case. +- [ ] Parse AACP ear-detection packets. +- [ ] Merge AACP and BLE status into `AirPodsState` without stale-value regressions. +- [ ] Expose charging and case-lid state. +- [ ] Show first reliable Connected + L/R/Case status in the Wear UI. + +## Phase 3 — Controls + +- [ ] Listening mode: ANC / Transparency / Off. +- [ ] Ear detection configuration. +- [ ] Conversation Awareness. +- [ ] One-bud ANC. +- [ ] Stem/control configuration. +- [ ] Rename and device information. + +## Phase 4 — Reliability + +- [ ] Automatic reconnect after Bluetooth loss. +- [ ] Handle AirPods switching between bonded/active devices. +- [ ] Prevent duplicate AACP sessions. +- [ ] Keep BLE monitoring alive independently from AACP. +- [ ] Persist the last known AirPods identity safely. + +## Phase 5 — Wear UX + +- [ ] Compact connection screen. +- [ ] Battery cards for L/R/Case. +- [ ] Listening mode control. +- [ ] Connection diagnostics screen. +- [ ] No unnecessary screen-off during pairing/connection flows. +- [ ] Minimize phone dependency and background work. + +## Phase 6 — Advanced LibrePods features + +- [ ] Proximity keys / encrypted BLE status. +- [ ] Advanced AirPods information. +- [ ] Custom EQ / accommodation. +- [ ] Head tracking where technically useful on Wear OS. +- [ ] Further LibrePods feature parity after the core stack is stable. + +## Rules for this port + +1. Prefer the existing LibrePods protocol implementation over inventing new packet formats. +2. Keep protocol logic independent from Compose/UI. +3. Keep comments and development-map entries in English. +4. Every meaningful architecture change gets a small commit. +5. Update this roadmap whenever a phase materially changes. +6. A build passing is not considered a connection milestone; real AACP packet exchange is. diff --git a/AI_ROADMAP_WEAR_2026-08-20.md b/AI_ROADMAP_WEAR_2026-08-20.md new file mode 100644 index 000000000..d29e0fb8e --- /dev/null +++ b/AI_ROADMAP_WEAR_2026-08-20.md @@ -0,0 +1,126 @@ +# LibrePods Wear — AI Roadmap + +Date: 2026-08-20 + +## Goal + +Build a fully autonomous Wear OS AirPods client. The watch communicates with AirPods directly; the phone is not required for normal operation. + +## Current architecture + +```text +Wear UI + | +AirPodsController + | +State / Commands / Events + | +AirPodsConnectionSession + | +AirPodsProtocolTransport + | +ATT / BLE / AACP protocol + | +AirPods +``` + +## Completed + +- Removed Linux product code from the Wear branch. +- Removed phone-only boot, media, gesture, sensor, billing and root/Xposed plumbing. +- Added `AirPodsController` as the Wear-facing core boundary. +- Added observable `AirPodsState` / `AirPodsStateStore`. +- Added typed `AirPodsCommand` and `AirPodsEvent` boundaries. +- Added `WearBluetoothScanner`. +- Added `WearBluetoothConnection` facade. +- Added `AirPodsConnectionSession` with owned AACP/ATT socket lifecycle. +- Added `AirPodsProtocolTransport` so protocol code can consume streams without owning Android Bluetooth sockets. +- Added `AirPodsConnectionTarget` for negotiated UUID/PSM transport parameters. +- Added bounded `AirPodsReconnectManager` with exponential backoff using the negotiated target. +- Added `LibrePodsWearService` and moved transport ownership into the service. +- Converted `BluetoothConnectionManager` into a deprecated compatibility facade bound to the owned session. +- Injected `AirPodsProtocolTransport` into `ATTManager`; ATT no longer owns or looks up a global socket. +- Added `AACPTransportBridge` for transitional packet writes/dispatch against the Wear-owned transport. +- Initialized AACP/BLE managers inside the Wear service. +- Kept existing AACP/BLE parsing and command logic intact while migrating lifecycle ownership. +- Added strict AACP handshake sequencing: handshake -> ACK -> feature flags -> feature ACK -> notification request. +- Started the AACP reader before the handshake write so a fast AirPods ACK cannot be lost. +- Reverted an unverified packet-framing abstraction rather than guessing AACP framing rules. +- Added a diagnostic Wear UI showing connection state and Left/Right/Case battery values. +- Treat battery value `255` as unknown in the UI instead of displaying an invalid percentage. +- Made Wear BLE discovery unfiltered and observable. +- Added BLE callback/error diagnostics to the Wear UI. +- Added Apple manufacturer detection and advertised service metadata to discovered devices. +- Added compact multi-device selection UI for Wear. +- Moved the controller/transport lifecycle into a bound foreground `LibrePodsWearService`; the activity only binds. +- Removed `BluetoothConnectionManager`; all protocol writes go through `AirPodsProtocolTransport`. +- Added `AirPodsController.submit()` command entry point and `refreshState()` after handshake. +- Rebuilt the Wear UI on Wear Compose Material 3 (`ScalingLazyColumn`, `ScreenScaffold`, `SwitchButton`). +- Switched pairing to the system Bluetooth settings flow; the app lists bonded devices. + +## Phase 1 — autonomous transport/core — DONE + +- Migrate `AACPManager.sendPacket()` from the compatibility facade to `AirPodsProtocolTransport`. +- Add an AACP reader loop owned by the connection session/controller. +- Route AACP received packets into `AACPManager.receivePacket()`. +- Resolve model-compatible L2CAP UUID/PSM parameters from existing discovery logic. +- Connect ATT reader lifecycle to session start/stop. +- Connect AACP reader lifecycle to session start/stop. +- Emit connection/protocol events from the session. +- Handle connection loss and invoke bounded reconnect. +- Remove `BluetoothConnectionManager` after AACP migration is complete. +- Remove remaining phone-owned connection lifecycle. + +## Phase 2 — state and controls — DONE + +- Parse L2CAP battery notifications into `AirPodsState`. +- Verify component mapping: Right=`0x02`, Left=`0x04`, Case=`0x08`. +- Map battery charging/disconnected status into the state model. +- Parse ear detection. +- Parse listening mode. +- Implement ANC / Transparency / Off commands. +- Implement ear detection command. +- Implement conversational awareness command/state. +- Refresh state after connection. + +## Phase 3 — legacy service migration — DONE + +- Move required callbacks and packet routing from legacy `AirPodsService`. +- Remove phone media, notification, telephony, widget, takeover and root-specific branches. +- Delete the legacy service only after all required protocol paths are migrated. + +## Phase 4 — UI + +- Diagnostic connection + battery screen — DONE. +- Device selection from system-bonded devices — DONE. +- Main AirPods screen — DONE. +- ANC / Transparency / Off — DONE. +- Ear detection — DONE. +- Conversational awareness — DONE. +- Connection/reconnect state on the main screen — DONE. +- Tile and optional complication — open. + +## Immediate next test + +1. Build the Wear APK. +2. Install on the watch. +3. Pair the AirPods through the watch's system Bluetooth settings. +4. Open LibrePods and grant Bluetooth/notification permissions. +5. Tap the paired AirPods row. +6. Confirm the state reaches `READY` and battery values appear. +7. Toggle ANC / Transparency / Off, ear detection and conversation awareness. + +## Future + +- Extract reusable protocol core for Android, Wear OS, Linux and Windows where practical. +- Build a native LibrePods Windows client after the Wear OS port is stable. + +## Development rules + +- Code comments: English. +- Roadmap and architecture documentation: English. +- Focused commits for cleanup and architecture changes. +- Never delete protocol code without checking dependencies first. +- Do not mix protocol changes with large UI refactors. +- The phone must never become a required runtime dependency of the Wear core. +- Do not invent protocol framing, UUIDs or PSM values; extract them from the existing implementation or protocol discovery. diff --git a/FUNDING.yml b/FUNDING.yml deleted file mode 100644 index d02535b0f..000000000 --- a/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: kavishdevar diff --git a/README.md b/README.md index c1cc4edb5..4292378fe 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,58 @@ -> [!WARNING] -> librepods.org is not an official website of the LibrePods project. It inaccurately claims to be the official website of the project by claiming copyrights and using the LibrePods logo in the footer. And at the same time, they say that the project is not affiliated with the LibrePods project or its developers. -> -> Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz) - - - - - LibrePods - +# LibrePods Wear -
- - - - - - - - - - - - - - - -
+> **Work in progress.** This repository is an experimental Wear OS port of [LibrePods](https://github.com/librepods-org/librepods). -# What is LibrePods? +LibrePods Wear is intended to control AirPods **directly from a Wear OS watch**. The watch must be able to discover, connect to and communicate with AirPods without requiring a phone companion for normal operation. -LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms. +## Project direction -# Feature availability +The port keeps the original LibrePods AirPods protocol implementation where it is useful and removes platform-specific parts that do not belong on a watch. -| Feature | Linux | Android | -| ----------------------------------------------------------- | ----- | ------- | -| Changing Listening Mode | ✅ | ✅ | -| Ear detection | ✅ | ✅ | -| Battery status | ✅ | ✅ | -| Renaming AirPods
Note for AndroidOn Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.
| ✅ | ✅ | -| Loud Sound Reduction | 🔴 | ⚪ | -| Head Gestures | ⛔ | ✅ | -| Conversational Awareness | ✅ | ✅ | -| Automatically connect to AirPods | ✅ | ✅ | -| Hearing Aid | 🔴 | ⚪ | -| Transparency Mode customization | 🔴 | ⚪ | -| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ | -|
Other accessibility configs (click to expand)
| 🔴 | ✅ | -|
Other general configs
| 🔴 | ✅ | -| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ | -| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 | -| [Find My](#find-my) | ❓ | ❓ | -| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 | +Target architecture: -| Symbol | Meaning | -| ------ | ------------------------------------------------------------------- | -| ✅ | Implemented and works well | -| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk | -| 🔴 | Not implemented yet; planned | -| ⛔ | Will not be implemented | -| ❓ | Unknown | +```text +Wear OS UI + | +Wear service / lifecycle + | +Wear Bluetooth layer + | +ATT / AACP protocol core + | +AirPods +``` -## Find My +The phone is optional and is not part of the core control path. -The following features related to Find My are planned, but require further RE and might need root on Android: +## Current status -- Add your AirPods to the Find My network -- Play sound through charging case to find it -- Notify when leaving behind -- Toggle case charging sounds +- [x] Fork LibrePods upstream +- [x] Add Wear OS development roadmap +- [x] Remove Linux application implementation +- [x] Remove root-module packaging +- [x] Remove native L2CAP/Xposed hook build from the Wear branch +- [x] Remove legacy phone UI and Quick Settings components +- [x] Reduce Android manifest to Wear/Bluetooth requirements +- [x] Create a minimal Wear launcher +- [ ] Isolate reusable AirPods protocol core +- [ ] Implement direct Wear OS Bluetooth connection +- [ ] AirPods discovery and pairing flow +- [ ] ATT/AACP connection +- [ ] Battery status +- [ ] Noise Control modes +- [ ] Ear detection +- [ ] Reconnect/background lifecycle +- [ ] Wear OS Tile +- [ ] Advanced LibrePods features -## Spatial Audio +See [`AI_ROADMAP_WEAR_2026-08-20.md`](./AI_ROADMAP_WEAR_2026-08-20.md) for the detailed development map. -The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root. +## Build -Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this. +The current branch is a **development cleanup stage** and has not yet been validated on a physical Wear OS device. Build/test work starts after the protocol and Wear Bluetooth layers are isolated. -## Heart Rate Monitoring (AirPods Pro 3 and later) -This is being worked upon, check the #⁠reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android. +## Upstream -## High quality two-way audio -On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP. +Original project: [librepods-org/librepods](https://github.com/librepods-org/librepods) -Since this needs deeper integration with audio on Android, it will most likely need root. - -# Installation - -- [**Android**](/android/README.md) -- [**Linux**](/linux/README.md) - -# VendorID Spoofing - -Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features! - -You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled). - -## Multi-device Connectivity - -Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over. - -## Accessibility Settings and Hearing Aid - -Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. - -All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. - -# Protocol and Reverse Engineering - -Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark) - -The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts! - -# Use of AI - -## Android app - -These parts of the app were completely AI-generated: -- Head Gestures - all of it, including logic and the UI -- The offset setup with r2+the xposed module (both versions) -- Troubleshooter and LogCollector - -Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually. - -Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). - -## Linux (rewrite) - -The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated. - -# Supporters - -A huge thank you to everyone supporting the project! - - - - - - - - - - - - - - - - - - - - - -
- - davdroman
- @davdroman -
-
- - tedsalmon
- @tedsalmon -
-
- - wiless
- @wiless -
-
- - SmartMsg
- @SmartMsg -
-
- - lunaroyster
- @lunaroyster -
-
- - ressiwage
- @ressiwage -
-
- - kkjdroid
- @kkjdroid -
-
- - CitrusJoules
- @CitrusJoules -
-
- - DanielReyesDev
- @DanielReyesDev -
-
- - sumitduster
- @sumitduster -
-
- - GrifTheDev
- @GrifTheDev -
-
- -# Special Thanks -- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion)) -- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016)) -- @devnoname120 for helping with the first root patch -- @timgromeyer for making the first version of the linux app -- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)! -- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest! - -# Alternates for other platforms: -- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted. -- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/)) -- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/)) - -# Star History - - - - - - Star History Chart - - - -# License - -LibrePods - AirPods liberated from Apple’s ecosystem -Copyright (C) 2025 LibrePods contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -# Trademark Notice - -The GPL does not grant any rights to use the LibrePods name, logo, or branding. The LibrePods name and logo may not be used for software, websites, domains, products, services, or other projects in a manner that suggests affiliation with, endorsement by, or association with the official LibrePods project without prior permission. - -If you see any misuse of the LibrePods name or logo, please report it to [me@kavish.xyz](mailto:me@kavish.xyz). - -The SF Pro font used in the Android app is the property of Apple Inc.. This will be removed in future versions of the app and replaced with an open alternative soon. - -AirPods, AirPods Pro, AirPods Max, and the AirPods logo are trademarks of Apple Inc. The LibrePods project is not affiliated with or endorsed by Apple Inc. in any way. +This fork remains GPL-3.0 licensed. Keep the original license and contributor attribution when modifying or redistributing the code. diff --git a/android/AI_ROADMAP_2026-08-21.md b/android/AI_ROADMAP_2026-08-21.md new file mode 100644 index 000000000..93883dbd6 --- /dev/null +++ b/android/AI_ROADMAP_2026-08-21.md @@ -0,0 +1,52 @@ +# LibrePods-Wear — Wear protocol roadmap (2026-08-25) + +## Architecture decision + +- System Bluetooth pairing/discovery is the authority; the app does not implement a competing pairing UI. +- Wear app works with already paired AirPods and owns the connection/protocol layer after selection. +- All connection state, AACP transport, protocol parsing and diagnostics live in the Wear stack. +- The UI stays compact and scrollable; protocol diagnostics are secondary to actual connection/status data. + +## Current milestone + +- System Bluetooth pairing flow is used to get AirPods onto the watch. +- Wear app selects an already paired AirPods device. +- Direct AACP classic L2CAP transport is owned by `AirPodsConnectionSession` / `WearBluetoothConnection`. +- Modern Android `BluetoothDevice.createL2capChannel(0x1001)` is preferred, with the legacy constructor path retained as fallback. +- AACP handshake is stateful: `IDLE -> HANDSHAKE_SENT -> FEATURES_SENT -> READY`. +- AACP packet callbacks are wired into `AirPodsController` using the actual `AACPManager.Companion` nested packet types. +- Battery packets are validated and decoded for left/right/case. +- Ear-detection packets are validated and decoded. +- Raw/unknown packets are retained in controller diagnostics for reverse-engineering. +- BLE advertisements remain a secondary status source for battery/ear/case data. +- Verified control commands are now exposed for listening mode, ear detection and conversational awareness. +- Bounded reconnect (3 attempts) is now part of the Wear controller. + +## Protocol work queue + +1. **AACP stream framing** — finish conservative framing for fragmented/coalesced reads. Only emit frames with a verified layout; never guess unknown lengths. +2. **Handshake framing** — make the handshake ACK and feature ACK survive split/coalesced reads without relying on Bluetooth `read()` boundaries. +3. **Device information** — decode metadata and expose model/name/firmware/serial fields where the packet layout is verified. +4. **Battery/status** — finish stable left/right/case percentage + charging + connected state handling. +5. **Ear detection** — finish stable left/right in-ear state and merge it with BLE status without stale overwrites. +6. **Listening mode** — verified write commands are implemented: Off=1, ANC=2, Transparency=3. Add verified read/status parsing. +7. **Conversation Awareness** — verified enable/disable write is implemented. Add verified status parsing. +8. **Stem/button control** — implement verified command/status handling. +9. **Connected devices / ownership** — decode and expose ownership and connected-device state. +10. **Reconnect** — bounded backoff, socket reset and clean AACP session restart are implemented; validate on hardware. +11. **ATT** — add only after AACP status is stable; do not make ATT a prerequisite for the first working connection. +12. **Protocol test vectors** — add captured packet fixtures; no guessed packet layouts. +13. **Wear UI** — keep the compact connection/status view and expose protocol stage + last opcode/hex only for debugging. + +## Current implementation block + +- Prefer the public Android L2CAP channel API for PSM `0x1001`. +- Add verified AACP control writes for listening mode, ear detection and conversation awareness. +- Add bounded reconnect after an unexpected AACP socket close. +- Keep unknown packet handling conservative until verified frame lengths are available. + +## Acceptance target + +`Paired AirPods -> Connect -> AACP READY -> left/right/case percentages -> charging flags -> left/right in-ear state -> verified ANC/Transparency/Off control -> clean disconnect/reconnect`. + +After that, expand the verified AACP control surface before touching secondary ATT/Find My functionality. diff --git a/android/BUILD_FIRST.md b/android/BUILD_FIRST.md new file mode 100644 index 000000000..49bbd9cb7 --- /dev/null +++ b/android/BUILD_FIRST.md @@ -0,0 +1,46 @@ +# First Wear OS build + +The current branch is prepared for the first debug APK build. + +## Local build + +From the repository root: + +```text +cd android +./gradlew :app:assembleDebug +``` + +On Windows: + +```text +cd android +gradlew.bat :app:assembleDebug +``` + +Expected APK: + +```text +android/app/build/outputs/apk/debug/app-debug.apk +``` + +## What the first build currently contains + +- Wear OS-only application entry point. +- Minimal Compose home screen. +- Bluetooth permissions and connected-device foreground service declaration. +- Wear Bluetooth scanner boundary. +- Wear Bluetooth connection facade. +- Owned AirPods L2CAP connection session. +- AACP/ATT transport boundary and migration scaffolding. +- AirPods controller state/command/event model. +- Background Wear service lifecycle. +- Existing LibrePods AACP/BLE protocol code retained for incremental migration. + +## Important + +This first APK is an architecture/integration checkpoint, not the finished AirPods controller. The Connect button is intentionally not wired to a production handshake yet. The next phase connects discovery, protocol parameters, L2CAP session, ATT/AACP readers, and state updates end-to-end. + +## Device target + +The app declares `android.hardware.type.watch` and targets Wear OS. It is intentionally not a phone companion application. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..830ddb92a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,245 +1,54 @@ -import java.util.Properties - -val appVersionName = "1.0.0-rc2" - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) - alias(libs.plugins.aboutLibraries) -// alias(libs.plugins.hilt) - id("kotlin-parcelize") -} - -val localPropsFile = rootProject.file("local.properties") -val props = Properties().apply { - if (localPropsFile.exists()) { - load(localPropsFile.inputStream()) - } -} - -val releaseSigningAvailable = listOf( - "RELEASE_STORE_FILE", - "RELEASE_STORE_PASSWORD", - "RELEASE_KEY_ALIAS", - "RELEASE_KEY_PASSWORD" -).all { props[it]?.toString()?.isNotBlank() == true } - -kotlin { - compilerOptions { - optIn.add( - "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi" - ) - } } android { - signingConfigs { - if (releaseSigningAvailable) { - create("release") { - storeFile = file(props["RELEASE_STORE_FILE"] as String) - storePassword = props["RELEASE_STORE_PASSWORD"] as String - keyAlias = props["RELEASE_KEY_ALIAS"] as String - keyPassword = props["RELEASE_KEY_PASSWORD"] as String - } - } - } namespace = "me.kavishdevar.librepods" compileSdk = 37 defaultConfig { - applicationId = "me.kavishdevar.librepods" + applicationId = "me.kavishdevar.librepods.wear" + minSdk = 30 targetSdk = 37 - versionCode = 63 - versionName = appVersionName + versionCode = 1 + versionName = "0.1.0-wear-dev" } + buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", ) - externalNativeBuild { - cmake { - arguments += "-DCMAKE_BUILD_TYPE=Release" - } - } - if (releaseSigningAvailable) { - signingConfig = signingConfigs.getByName("release") - } - defaultConfig { - minSdk = 33 - } } debug { - if (releaseSigningAvailable) { - signingConfig = signingConfigs.getByName("release") - } versionNameSuffix = "-debug" - defaultConfig { - minSdk = 33 - } - } - } - productFlavors { - create("foss") { - dimension = "env" - buildConfigField("Boolean", "PLAY_BUILD", "false") - } - create("play") { - dimension = "env" - buildConfigField("Boolean", "PLAY_BUILD", "true") - versionNameSuffix = "-play" - minSdk = 36 } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } + buildFeatures { compose = true - viewBinding = true - buildConfig = true - } - androidResources { - generateLocaleConfig = true - } - externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - version = "3.22.1" - } - } - sourceSets { - getByName("main") { - res.directories += "src/main/res-apple" - } } - - ndkVersion = "30.0.14904198" - - flavorDimensions += "env" } dependencies { implementation(platform(libs.androidx.compose.bom)) - implementation(libs.accompanist.permissions) - implementation(libs.androidx.compose.ui.text.google.fonts) implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.process) - implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.androidx.wear.compose.foundation) implementation(libs.annotations) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.constraintlayout) - implementation(libs.haze) - implementation(libs.haze.materials) - implementation(libs.androidx.dynamicanimation) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material.icons.core) - implementation(libs.billing) + implementation(libs.androidx.lifecycle.runtime.ktx) debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.foundation.layout) - implementation(libs.aboutlibraries) - implementation(libs.aboutlibraries.compose.m3) - implementation(libs.backdrop) -// implementation(libs.hilt) -// implementation(libs.hilt.compiler) - compileOnly(libs.libxposed.api) - implementation(libs.libxposed.service) - implementation(libs.play.review) - implementation(libs.play.review.ktx) - implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.navigation3.runtime) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.androidx.navigationevent) -} - -aboutLibraries { - export { - prettyPrint = true - excludeFields = listOf("generated") - outputFile = file("src/main/res/raw/aboutlibraries.json") - } -} - -val rootModuleDir = rootProject.file("../root-module-manual") -val releaseDir = rootProject.file("../release") - -fun cap(s: String) = s.replaceFirstChar { it.uppercase() } - -fun registerRootModuleZipTask( - name: String, - flavor: String, - buildType: String -) = tasks.register(name) { - - val variantTask = "assemble${cap(flavor)}${cap(buildType)}" - dependsOn(variantTask) - - val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk" - - from(rootModuleDir) - - duplicatesStrategy = DuplicatesStrategy.WARN - - from(layout.buildDirectory.file(apkPath)) { - into("system/priv-app/LibrePods") - rename { "LibrePods.apk" } - } - - delete(layout.buildDirectory.dir("outputs/rootModuleZips")) - - archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip") - destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips")) -} - -val zipRelease = registerRootModuleZipTask( - "zipReleaseModule", - "foss", - "release" -) - -val zipDebug = registerRootModuleZipTask( - "zipDebugModule", - "foss", - "debug" -) - -val collect = tasks.register("collectReleaseArtifacts") { - - dependsOn( - zipRelease, - zipDebug, - "bundlePlayRelease" - ) - - into(releaseDir) - - from(layout.buildDirectory.dir("outputs/apk/foss/release")) { - include("*.apk") - rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk") - } - - from(layout.buildDirectory.dir("outputs/apk/foss/debug")) { - include("*.apk") - rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk") - } - - from(layout.buildDirectory.dir("outputs/bundle/playRelease")) { - include("*.aab") - } - - from(layout.buildDirectory.dir("outputs/rootModuleZips")) { - include("*.zip") - } -} - -tasks.register("packageReleaseArtifacts") { - dependsOn(collect) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0474dfd88..1e8cec890 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,158 +1,48 @@ - + + + android:name="android.hardware.type.watch" + android:required="true" /> - - - - - - - - - - - + + - - - - - - - - - - + android:usesPermissionFlags="neverForLocation" /> + + + - - - - + android:theme="@style/Theme.LibrePods"> - - - - - - - - - - + + android:exported="true"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:foregroundServiceType="connectedDevice" /> diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index 02de88f33..000000000 --- a/android/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,57 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) - -project("l2c_fcr_hook") -set(CMAKE_CXX_STANDARD 23) - -add_library(bluetooth_socket SHARED - bluetooth_socket.cpp -) - -target_compile_options(bluetooth_socket PRIVATE - -O2 - -fvisibility=hidden -) - -target_link_options(bluetooth_socket PRIVATE - -Wl,--strip-all - -Wl,--gc-sections -) - -target_link_libraries(bluetooth_socket - android - log -) - - -set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp) - -add_library(l2c_fcr_hook SHARED - l2c_fcr_hook.cpp - - xz/xz_crc32.c - xz/xz_crc64.c - xz/xz_sha256.c - xz/xz_dec_stream.c - xz/xz_dec_lzma2.c - xz/xz_dec_bcj.c -) - -target_include_directories(l2c_fcr_hook PRIVATE - xz -) - -target_compile_definitions(l2c_fcr_hook PRIVATE - XZ_DEC_X86 - XZ_DEC_ARM - XZ_DEC_ARMTHUMB - XZ_DEC_ARM64 - XZ_DEC_ANY_CHECK - XZ_USE_CRC64 - XZ_USE_SHA256 - XZ_DEC_CONCATENATED -) - -target_link_libraries(l2c_fcr_hook - android - log -) diff --git a/android/app/src/main/cpp/bluetooth_socket.cpp b/android/app/src/main/cpp/bluetooth_socket.cpp deleted file mode 100644 index 865d4216b..000000000 --- a/android/app/src/main/cpp/bluetooth_socket.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include -#include -#include -#include - -static JavaVM* gVm = nullptr; - -template -constexpr auto encryptString(const char (&str)[N], char key) { - std::array encrypted{}; - for (size_t i = 0; i < N; i++) { - encrypted[i] = str[i] ^ key; - } - return encrypted; -} - -template -static std::string decryptString(const std::array& encrypted, char key) { - std::string result(N - 1, '\0'); - for (size_t i = 0; i < N - 1; i++) { - result[i] = encrypted[i] ^ key; - } - return result; -} - -#define ENC(str) encryptString(str, 0x47) -#define DEC(arr) decryptString(arr, 0x47).c_str() - -__attribute__((visibility("hidden"))) -static JavaVM* getVm() { return gVm; } - -__attribute__((visibility("default"))) -JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { - gVm = vm; - - auto fn = [](void*) -> void* { - constexpr auto c1 = ENC("dalvik/system/VMRuntime"); - constexpr auto c2 = ENC("getRuntime"); - constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;"); - constexpr auto c4 = ENC("setHiddenApiExemptions"); - constexpr auto c5 = ENC("([Ljava/lang/String;)V"); - constexpr auto c6 = ENC("java/lang/String"); - constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;"); - constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;"); - - JNIEnv* env; - getVm()->AttachCurrentThread(&env, nullptr); - - jclass vmRuntime = env->FindClass(DEC(c1)); - jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3)); - jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5)); - - jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime); - jobjectArray prefixes = env->NewObjectArray( - 2, env->FindClass(DEC(c6)), nullptr); - env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7))); - env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8))); - - env->CallVoidMethod(runtime, setExemptions, prefixes); - getVm()->DetachCurrentThread(); - return nullptr; - }; - - pthread_t t; - pthread_create(&t, nullptr, fn, nullptr); - pthread_join(t, nullptr); - return JNI_VERSION_1_6; -} diff --git a/android/app/src/main/cpp/l2c_fcr_hook.cpp b/android/app/src/main/cpp/l2c_fcr_hook.cpp deleted file mode 100644 index 529f86270..000000000 --- a/android/app/src/main/cpp/l2c_fcr_hook.cpp +++ /dev/null @@ -1,435 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "l2c_fcr_hook.h" - -extern "C" { -#include "xz.h" -} - -#define LOG_TAG "LibrePodsHook" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -static HookFunType hook_func = nullptr; - -static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr; - -static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr; - -static std::atomic enableSdpHook(false); - -uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) { - LOGI("fake_l2c_fcr_chk_chan_modes called"); - uint8_t orig = 0; - if (original_l2c_fcr_chk_chan_modes) - orig = original_l2c_fcr_chk_chan_modes(p_ccb); - - LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig); - return 1; -} - -tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) { - - LOGI("fake_BTA_DmSetLocalDiRecord called"); - - if (original_BTA_DmSetLocalDiRecord && - enableSdpHook.load(std::memory_order_relaxed)) - original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); - - LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001"); - - if (p_device_info) { - p_device_info->vendor = 0x004C; - p_device_info->vendor_id_source = 0x0001; - } - - LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", - original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) - : BTA_FAILURE); - return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, - p_handle) - : BTA_FAILURE; -} - -static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector &output) { - - LOGI("decompressXZ called with input_size: %zu", input_size); - - xz_crc32_init(); -#ifdef XZ_USE_CRC64 - xz_crc64_init(); -#endif - - struct xz_dec *dec = xz_dec_init(XZ_DYNALLOC, 64U << 20); - if (!dec) { - LOGE("decompressXZ: xz_dec_init failed"); - return false; - } - LOGI("decompressXZ: xz_dec_init succeeded"); - - struct xz_buf buf{}; - buf.in = input; - buf.in_pos = 0; - buf.in_size = input_size; - - output.resize(input_size * 8); - - buf.out = output.data(); - buf.out_pos = 0; - buf.out_size = output.size(); - - LOGI("decompressXZ: entering decompression loop"); - while (true) { - LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos, - buf.out_pos); - enum xz_ret ret = xz_dec_run(dec, &buf); - - LOGI("decompressXZ: xz_dec_run returned %d", ret); - - if (ret == XZ_STREAM_END) - break; - - if (ret != XZ_OK) { - LOGE("decompressXZ: xz_dec_run error"); - xz_dec_end(dec); - return false; - } - - if (buf.out_pos == buf.out_size) { - size_t old = output.size(); - LOGI("decompressXZ: resizing output to %zu", old * 2); - output.resize(old * 2); - buf.out = output.data(); - buf.out_size = output.size(); - } - } - - output.resize(buf.out_pos); - xz_dec_end(dec); - LOGI("decompressXZ: decompression successful, output size: %zu", output.size()); - return true; -} - -static bool getLibraryPath(const char *name, std::string &out) { - LOGI("getLibraryPath called with name: %s", name); - - FILE *fp = fopen("/proc/self/maps", "r"); - if (!fp) { - LOGE("getLibraryPath: fopen failed"); - return false; - } - - char line[1024]; - - LOGI("getLibraryPath: scanning /proc/self/maps"); - while (fgets(line, sizeof(line), fp)) { - if (strstr(line, name)) { - LOGI("getLibraryPath: found line containing %s", name); - char *path = strchr(line, '/'); - if (path) { - out = path; - out.erase(out.find('\n')); - LOGI("getLibraryPath: path found: %s", out.c_str()); - fclose(fp); - return true; - } - } - } - - fclose(fp); - LOGI("getLibraryPath: failed to find path for %s", name); - return false; -} - -static uintptr_t getModuleBase(const char *name) { - LOGI("getModuleBase called with name: %s", name); - - FILE *fp = fopen("/proc/self/maps", "r"); - if (!fp) { - LOGE("getModuleBase: fopen failed"); - return 0; - } - - char line[1024]; - uintptr_t base = 0; - - LOGI("getModuleBase: scanning /proc/self/maps"); - while (fgets(line, sizeof(line), fp)) { - if (strstr(line, name)) { - base = strtoull(line, nullptr, 16); - LOGI("getModuleBase: found base at 0x%lx", base); - break; - } - } - - fclose(fp); - LOGI("getModuleBase: failed to find base for %s", name); - return base; -} - -static uint64_t -findSymbolOffsetDynsym(const std::vector &elf, const char *symbol_substring) { - - LOGI("findSymbolOffsetDynsym called with %s", symbol_substring); - - auto *eh = reinterpret_cast(elf.data()); - auto *shdr = reinterpret_cast( - elf.data() + eh->e_shoff); - - const char *shstr = reinterpret_cast( - elf.data() + shdr[eh->e_shstrndx].sh_offset); - - const Elf64_Shdr *dynsym = nullptr; - const Elf64_Shdr *dynstr = nullptr; - - for (int i = 0; i < eh->e_shnum; ++i) { - const char *secname = shstr + shdr[i].sh_name; - - if (!strcmp(secname, ".dynsym")) - dynsym = &shdr[i]; - if (!strcmp(secname, ".dynstr")) - dynstr = &shdr[i]; - } - - if (!dynsym || !dynstr) { - LOGE("findSymbolOffsetDynsym: dynsym or dynstr not found"); - return 0; - } - - auto *symbols = reinterpret_cast( - elf.data() + dynsym->sh_offset); - - const char *strings = reinterpret_cast( - elf.data() + dynstr->sh_offset); - - size_t count = dynsym->sh_size / sizeof(Elf64_Sym); - - LOGI("findSymbolOffsetDynsym: scanning %zu symbols", count); - - for (size_t i = 0; i < count; ++i) { - const char *name = strings + symbols[i].st_name; - - if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) { - - LOGI("findSymbolOffsetDynsym: matched %s @ 0x%lx", name, - (unsigned long) symbols[i].st_value); - - return symbols[i].st_value; - } - } - - LOGI("findSymbolOffsetDynsym: no match for %s", symbol_substring); - return 0; -} - -static uint64_t findSymbolOffset(const std::vector &elf, const char *symbol_substring) { - - LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring); - - auto *eh = reinterpret_cast(elf.data()); - auto *shdr = reinterpret_cast( - elf.data() + eh->e_shoff); - - const char *shstr = reinterpret_cast( - elf.data() + shdr[eh->e_shstrndx].sh_offset); - - const Elf64_Shdr *symtab = nullptr; - const Elf64_Shdr *strtab = nullptr; - - LOGI("findSymbolOffset: parsing ELF sections"); - for (int i = 0; i < eh->e_shnum; ++i) { - const char *secname = shstr + shdr[i].sh_name; - if (!strcmp(secname, ".symtab")) - symtab = &shdr[i]; - if (!strcmp(secname, ".strtab")) - strtab = &shdr[i]; - } - - if (!symtab || !strtab) { - LOGE("findSymbolOffset: symtab or strtab not found"); - return 0; - } - LOGI("findSymbolOffset: found symtab and strtab"); - - auto *symbols = reinterpret_cast( - elf.data() + symtab->sh_offset); - - const char *strings = reinterpret_cast( - elf.data() + strtab->sh_offset); - - size_t count = symtab->sh_size / sizeof(Elf64_Sym); - - LOGI("findSymbolOffset: scanning %zu symbols", count); - for (size_t i = 0; i < count; ++i) { - const char *name = strings + symbols[i].st_name; - - if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) { - - LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name, - (unsigned long) symbols[i].st_value); - - return symbols[i].st_value; - } - } - - LOGI("findSymbolOffset: no match found for %s", symbol_substring); - return 0; -} - -static bool hookLibrary(const char *libname) { - LOGI("hookLibrary called with libname: %s", libname); - - if (!hook_func) { - LOGE("hook_func not initialized"); - return false; - } - - std::string path; - if (!getLibraryPath(libname, path)) { - LOGE("Failed to locate %s", libname); - return false; - } - LOGI("hookLibrary: located path: %s", path.c_str()); - - int fd = open(path.c_str(), O_RDONLY); - if (fd < 0) { - LOGE("hookLibrary: open failed"); - return false; - } - - struct stat st{}; - if (fstat(fd, &st) != 0) { - LOGE("hookLibrary: fstat failed"); - close(fd); - return false; - } - LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size); - - std::vector file(st.st_size); - read(fd, file.data(), st.st_size); - close(fd); - - auto *eh = reinterpret_cast(file.data()); - auto *shdr = reinterpret_cast( - file.data() + eh->e_shoff); - - const char *shstr = reinterpret_cast( - file.data() + shdr[eh->e_shstrndx].sh_offset); - - uint64_t chk_offset = 0; - uint64_t sdp_offset = 0; - - for (int i = 0; i < eh->e_shnum; ++i) { - if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) { - LOGI("hookLibrary: found .gnu_debugdata section"); - - std::vector compressed(file.begin() + shdr[i].sh_offset, - file.begin() + shdr[i].sh_offset + shdr[i].sh_size); - - std::vector decompressed; - - if (decompressXZ(compressed.data(), compressed.size(), decompressed)) { - - chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes"); - - sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord"); - } else { - LOGE("debugdata decompress failed"); - } - - break; - } - } - - if (!chk_offset) { - LOGI("fallback dynsym chk"); - chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes"); - } - - if (!sdp_offset) { - LOGI("fallback dynsym sdp"); - sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord"); - } - - uintptr_t base = getModuleBase(libname); - if (!base) { - LOGE("hookLibrary: getModuleBase failed"); - return false; - } - - if (chk_offset) { - void *target = reinterpret_cast(base + chk_offset); - hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes, - (void **) &original_l2c_fcr_chk_chan_modes); - LOGI("hooked chk"); - } - - if (sdp_offset) { - void *target = reinterpret_cast(base + sdp_offset); - hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord, - (void **) &original_BTA_DmSetLocalDiRecord); - LOGI("hooked sdp"); - } - - return chk_offset || sdp_offset; -} - -static void on_library_loaded(const char *name, void *) { - LOGI("on_library_loaded called with name: %s", name); - - if (strstr(name, "libbluetooth_jni.so")) { - LOGI("Bluetooth JNI loaded"); - hookLibrary("libbluetooth_jni.so"); - } - - if (strstr(name, "libbluetooth_qti.so")) { - LOGI("Bluetooth QTI loaded"); - hookLibrary("libbluetooth_qti.so"); - } -} - -extern "C" [[gnu::visibility("default")]] -[[gnu::used]] -NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { - LOGI("native_init called with entries: %p", entries); - hook_func = (HookFunType) entries->hook_func; - LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", - enableSdpHook.load(std::memory_order_relaxed)); - return on_library_loaded; -} - -extern "C" JNIEXPORT void JNICALL -Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz, - jboolean enable) { - LOGI("setSdpHook called with enable: %d", enable); - enableSdpHook.store(enable, std::memory_order_relaxed); - - LOGI("sdp hook enabled: %d", enable); -} diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/main/cpp/l2c_fcr_hook.h deleted file mode 100644 index 0ca78516f..000000000 --- a/android/app/src/main/cpp/l2c_fcr_hook.h +++ /dev/null @@ -1,52 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -#pragma once -#include - -typedef int (*HookFunType)(void *func, void *replace, void **backup); - -typedef void (*NativeOnModuleLoaded)(const char *name, void *handle); - -typedef struct { - uint32_t version; - void* hook_func; - void* unhook_func; -} NativeAPIEntries; - -typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries); - -typedef enum : uint8_t { - BTA_SUCCESS = 0, /* Successful operation. */ - BTA_FAILURE = 1, /* Generic failure. */ - BTA_PENDING = 2, /* API cannot be completed right now */ - BTA_BUSY = 3, - BTA_NO_RESOURCES = 4, - BTA_WRONG_MODE = 5, -} tBTA_STATUS; - -typedef struct t_sdp_di_record { - uint16_t vendor; - uint16_t vendor_id_source; - uint16_t product; - uint16_t version; - bool primary_record; - char client_executable_url[400]; - char service_description[400]; - char documentation_url[400]; -} tSDP_DI_RECORD; diff --git a/android/app/src/main/cpp/xz/xz.h b/android/app/src/main/cpp/xz/xz.h deleted file mode 100644 index c317c4941..000000000 --- a/android/app/src/main/cpp/xz/xz.h +++ /dev/null @@ -1,448 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -/* - * XZ decompressor - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -#ifndef XZ_H -#define XZ_H - -#ifdef __KERNEL__ -# include -# include -#else -# include -# include -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -/* "#define XZ_EXTERN static" can be used to make extern functions static. */ -#ifndef XZ_EXTERN -# define XZ_EXTERN extern -#endif - -/** - * enum xz_mode - Operation mode - * - * @XZ_SINGLE: Single-call mode. This uses less RAM than - * multi-call modes, because the LZMA2 - * dictionary doesn't need to be allocated as - * part of the decoder state. All required data - * structures are allocated at initialization, - * so xz_dec_run() cannot return XZ_MEM_ERROR. - * @XZ_PREALLOC: Multi-call mode with preallocated LZMA2 - * dictionary buffer. All data structures are - * allocated at initialization, so xz_dec_run() - * cannot return XZ_MEM_ERROR. - * @XZ_DYNALLOC: Multi-call mode. The LZMA2 dictionary is - * allocated once the required size has been - * parsed from the stream headers. If the - * allocation fails, xz_dec_run() will return - * XZ_MEM_ERROR. - * - * It is possible to enable support only for a subset of the above - * modes at compile time by defining XZ_DEC_SINGLE, XZ_DEC_PREALLOC, - * or XZ_DEC_DYNALLOC. The xz_dec kernel module is always compiled - * with support for all operation modes, but the preboot code may - * be built with fewer features to minimize code size. - */ -enum xz_mode { - XZ_SINGLE, - XZ_PREALLOC, - XZ_DYNALLOC -}; - -/** - * enum xz_ret - Return codes - * @XZ_OK: Everything is OK so far. More input or more - * output space is required to continue. This - * return code is possible only in multi-call mode - * (XZ_PREALLOC or XZ_DYNALLOC). - * @XZ_STREAM_END: Operation finished successfully. - * @XZ_UNSUPPORTED_CHECK: Integrity check type is not supported. Decoding - * is still possible in multi-call mode by simply - * calling xz_dec_run() again. - * Note that this return value is used only if - * XZ_DEC_ANY_CHECK was defined at build time, - * which is not used in the kernel. Unsupported - * check types return XZ_OPTIONS_ERROR if - * XZ_DEC_ANY_CHECK was not defined at build time. - * @XZ_MEM_ERROR: Allocating memory failed. This return code is - * possible only if the decoder was initialized - * with XZ_DYNALLOC. The amount of memory that was - * tried to be allocated was no more than the - * dict_max argument given to xz_dec_init(). - * @XZ_MEMLIMIT_ERROR: A bigger LZMA2 dictionary would be needed than - * allowed by the dict_max argument given to - * xz_dec_init(). This return value is possible - * only in multi-call mode (XZ_PREALLOC or - * XZ_DYNALLOC); the single-call mode (XZ_SINGLE) - * ignores the dict_max argument. - * @XZ_FORMAT_ERROR: File format was not recognized (wrong magic - * bytes). - * @XZ_OPTIONS_ERROR: This implementation doesn't support the requested - * compression options. In the decoder this means - * that the header CRC32 matches, but the header - * itself specifies something that we don't support. - * @XZ_DATA_ERROR: Compressed data is corrupt. - * @XZ_BUF_ERROR: Cannot make any progress. Details are slightly - * different between multi-call and single-call - * mode; more information below. - * - * In multi-call mode, XZ_BUF_ERROR is returned when two consecutive calls - * to XZ code cannot consume any input and cannot produce any new output. - * This happens when there is no new input available, or the output buffer - * is full while at least one output byte is still pending. Assuming your - * code is not buggy, you can get this error only when decoding a compressed - * stream that is truncated or otherwise corrupt. - * - * In single-call mode, XZ_BUF_ERROR is returned only when the output buffer - * is too small or the compressed input is corrupt in a way that makes the - * decoder produce more output than the caller expected. When it is - * (relatively) clear that the compressed input is truncated, XZ_DATA_ERROR - * is used instead of XZ_BUF_ERROR. - */ -enum xz_ret { - XZ_OK, - XZ_STREAM_END, - XZ_UNSUPPORTED_CHECK, - XZ_MEM_ERROR, - XZ_MEMLIMIT_ERROR, - XZ_FORMAT_ERROR, - XZ_OPTIONS_ERROR, - XZ_DATA_ERROR, - XZ_BUF_ERROR -}; - -/** - * struct xz_buf - Passing input and output buffers to XZ code - * @in: Beginning of the input buffer. This may be NULL if and only - * if in_pos is equal to in_size. - * @in_pos: Current position in the input buffer. This must not exceed - * in_size. - * @in_size: Size of the input buffer - * @out: Beginning of the output buffer. This may be NULL if and only - * if out_pos is equal to out_size. - * @out_pos: Current position in the output buffer. This must not exceed - * out_size. - * @out_size: Size of the output buffer - * - * Only the contents of the output buffer from out[out_pos] onward, and - * the variables in_pos and out_pos are modified by the XZ code. - */ -struct xz_buf { - const uint8_t *in; - size_t in_pos; - size_t in_size; - - uint8_t *out; - size_t out_pos; - size_t out_size; -}; - -/* - * struct xz_dec - Opaque type to hold the XZ decoder state - */ -struct xz_dec; - -/** - * xz_dec_init() - Allocate and initialize a XZ decoder state - * @mode: Operation mode - * @dict_max: Maximum size of the LZMA2 dictionary (history buffer) for - * multi-call decoding. This is ignored in single-call mode - * (mode == XZ_SINGLE). LZMA2 dictionary is always 2^n bytes - * or 2^n + 2^(n-1) bytes (the latter sizes are less common - * in practice), so other values for dict_max don't make sense. - * In the kernel, dictionary sizes of 64 KiB, 128 KiB, 256 KiB, - * 512 KiB, and 1 MiB are probably the only reasonable values, - * except for kernel and initramfs images where a bigger - * dictionary can be fine and useful. - * - * Single-call mode (XZ_SINGLE): xz_dec_run() decodes the whole stream at - * once. The caller must provide enough output space or the decoding will - * fail. The output space is used as the dictionary buffer, which is why - * there is no need to allocate the dictionary as part of the decoder's - * internal state. - * - * Because the output buffer is used as the workspace, streams encoded using - * a big dictionary are not a problem in single-call mode. It is enough that - * the output buffer is big enough to hold the actual uncompressed data; it - * can be smaller than the dictionary size stored in the stream headers. - * - * Multi-call mode with preallocated dictionary (XZ_PREALLOC): dict_max bytes - * of memory is preallocated for the LZMA2 dictionary. This way there is no - * risk that xz_dec_run() could run out of memory, since xz_dec_run() will - * never allocate any memory. Instead, if the preallocated dictionary is too - * small for decoding the given input stream, xz_dec_run() will return - * XZ_MEMLIMIT_ERROR. Thus, it is important to know what kind of data will be - * decoded to avoid allocating excessive amount of memory for the dictionary. - * - * Multi-call mode with dynamically allocated dictionary (XZ_DYNALLOC): - * dict_max specifies the maximum allowed dictionary size that xz_dec_run() - * may allocate once it has parsed the dictionary size from the stream - * headers. This way excessive allocations can be avoided while still - * limiting the maximum memory usage to a sane value to prevent running the - * system out of memory when decompressing streams from untrusted sources. - * - * On success, xz_dec_init() returns a pointer to struct xz_dec, which is - * ready to be used with xz_dec_run(). If memory allocation fails, - * xz_dec_init() returns NULL. - */ -XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max); - -/** - * xz_dec_run() - Run the XZ decoder for a single XZ stream - * @s: Decoder state allocated using xz_dec_init() - * @b: Input and output buffers - * - * The possible return values depend on build options and operation mode. - * See enum xz_ret for details. - * - * Note that if an error occurs in single-call mode (return value is not - * XZ_STREAM_END), b->in_pos and b->out_pos are not modified and the - * contents of the output buffer from b->out[b->out_pos] onward are - * undefined. This is true even after XZ_BUF_ERROR, because with some filter - * chains, there may be a second pass over the output buffer, and this pass - * cannot be properly done if the output buffer is truncated. Thus, you - * cannot give the single-call decoder a too small buffer and then expect to - * get that amount valid data from the beginning of the stream. You must use - * the multi-call decoder if you don't want to uncompress the whole stream. - * - * Use xz_dec_run() when XZ data is stored inside some other file format. - * The decoding will stop after one XZ stream has been decompressed. To - * decompress regular .xz files which might have multiple concatenated - * streams, use xz_dec_catrun() instead. - */ -XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b); - -/** - * xz_dec_catrun() - Run the XZ decoder with support for concatenated streams - * @s: Decoder state allocated using xz_dec_init() - * @b: Input and output buffers - * @finish: This is an int instead of bool to avoid requiring stdbool.h. - * As long as more input might be coming, finish must be false. - * When the caller knows that it has provided all the input to - * the decoder (some possibly still in b->in), it must set finish - * to true. Only when finish is true can this function return - * XZ_STREAM_END to indicate successful decompression of the - * file. In single-call mode (XZ_SINGLE) finish is assumed to - * always be true; the caller-provided value is ignored. - * - * This is like xz_dec_run() except that this makes it easy to decode .xz - * files with multiple streams (multiple .xz files concatenated as is). - * The rarely-used Stream Padding feature is supported too, that is, there - * can be null bytes after or between the streams. The number of null bytes - * must be a multiple of four. - * - * When finish is false and b->in_pos == b->in_size, it is possible that - * XZ_BUF_ERROR isn't returned even when no progress is possible (XZ_OK is - * returned instead). This shouldn't matter because in this situation a - * reasonable caller will attempt to provide more input or set finish to - * true for the next xz_dec_catrun() call anyway. - * - * For any struct xz_dec that has been initialized for multi-call mode: - * Once decoding has been started with xz_dec_run() or xz_dec_catrun(), - * the same function must be used until xz_dec_reset() or xz_dec_end(). - * Switching between the two decoding functions without resetting results - * in undefined behavior. - * - * xz_dec_catrun() is only available if XZ_DEC_CONCATENATED was defined - * at compile time. - */ -XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b, - int finish); - -/** - * xz_dec_reset() - Reset an already allocated decoder state - * @s: Decoder state allocated using xz_dec_init() - * - * This function can be used to reset the multi-call decoder state without - * freeing and reallocating memory with xz_dec_end() and xz_dec_init(). - * - * In single-call mode, xz_dec_reset() is always called in the beginning of - * xz_dec_run(). Thus, explicit call to xz_dec_reset() is useful only in - * multi-call mode. - */ -XZ_EXTERN void xz_dec_reset(struct xz_dec *s); - -/** - * xz_dec_end() - Free the memory allocated for the decoder state - * @s: Decoder state allocated using xz_dec_init(). If s is NULL, - * this function does nothing. - */ -XZ_EXTERN void xz_dec_end(struct xz_dec *s); - -/** - * DOC: MicroLZMA decompressor - * - * This MicroLZMA header format was created for use in EROFS but may be used - * by others too. **In most cases one needs the XZ APIs above instead.** - * - * The compressed format supported by this decoder is a raw LZMA stream - * whose first byte (always 0x00) has been replaced with bitwise-negation - * of the LZMA properties (lc/lp/pb) byte. For example, if lc/lp/pb is - * 3/0/2, the first byte is 0xA2. This way the first byte can never be 0x00. - * Just like with LZMA2, lc + lp <= 4 must be true. The LZMA end-of-stream - * marker must not be used. The unused values are reserved for future use. - */ - -/* - * struct xz_dec_microlzma - Opaque type to hold the MicroLZMA decoder state - */ -struct xz_dec_microlzma; - -/** - * xz_dec_microlzma_alloc() - Allocate memory for the MicroLZMA decoder - * @mode: XZ_SINGLE or XZ_PREALLOC - * @dict_size: LZMA dictionary size. This must be at least 4 KiB and - * at most 3 GiB. - * - * In contrast to xz_dec_init(), this function only allocates the memory - * and remembers the dictionary size. xz_dec_microlzma_reset() must be used - * before calling xz_dec_microlzma_run(). - * - * The amount of allocated memory is a little less than 30 KiB with XZ_SINGLE. - * With XZ_PREALLOC also a dictionary buffer of dict_size bytes is allocated. - * - * On success, xz_dec_microlzma_alloc() returns a pointer to - * struct xz_dec_microlzma. If memory allocation fails or - * dict_size is invalid, NULL is returned. - */ -XZ_EXTERN struct xz_dec_microlzma *xz_dec_microlzma_alloc(enum xz_mode mode, - uint32_t dict_size); - -/** - * xz_dec_microlzma_reset() - Reset the MicroLZMA decoder state - * @s: Decoder state allocated using xz_dec_microlzma_alloc() - * @comp_size: Compressed size of the input stream - * @uncomp_size: Uncompressed size of the input stream. A value smaller - * than the real uncompressed size of the input stream can - * be specified if uncomp_size_is_exact is set to false. - * uncomp_size can never be set to a value larger than the - * expected real uncompressed size because it would eventually - * result in XZ_DATA_ERROR. - * @uncomp_size_is_exact: This is an int instead of bool to avoid - * requiring stdbool.h. This should normally be set to true. - * When this is set to false, error detection is weaker. - */ -XZ_EXTERN void xz_dec_microlzma_reset(struct xz_dec_microlzma *s, - uint32_t comp_size, uint32_t uncomp_size, - int uncomp_size_is_exact); - -/** - * xz_dec_microlzma_run() - Run the MicroLZMA decoder - * @s: Decoder state initialized using xz_dec_microlzma_reset() - * @b: Input and output buffers - * - * This works similarly to xz_dec_run() with a few important differences. - * Only the differences are documented here. - * - * The only possible return values are XZ_OK, XZ_STREAM_END, and - * XZ_DATA_ERROR. This function cannot return XZ_BUF_ERROR: if no progress - * is possible due to lack of input data or output space, this function will - * keep returning XZ_OK. Thus, the calling code must be written so that it - * will eventually provide input and output space matching (or exceeding) - * comp_size and uncomp_size arguments given to xz_dec_microlzma_reset(). - * If the caller cannot do this (for example, if the input file is truncated - * or otherwise corrupt), the caller must detect this error by itself to - * avoid an infinite loop. - * - * If the compressed data seems to be corrupt, XZ_DATA_ERROR is returned. - * This can happen also when incorrect dictionary, uncompressed, or - * compressed sizes have been specified. - * - * With XZ_PREALLOC only: As an extra feature, b->out may be NULL to skip over - * uncompressed data. This way the caller doesn't need to provide a temporary - * output buffer for the bytes that will be ignored. - * - * With XZ_SINGLE only: In contrast to xz_dec_run(), the return value XZ_OK - * is also possible and thus XZ_SINGLE is actually a limited multi-call mode. - * After XZ_OK the bytes decoded so far may be read from the output buffer. - * It is possible to continue decoding but the variables b->out and b->out_pos - * MUST NOT be changed by the caller. Increasing the value of b->out_size is - * allowed to make more output space available; one doesn't need to provide - * space for the whole uncompressed data on the first call. The input buffer - * may be changed normally like with XZ_PREALLOC. This way input data can be - * provided from non-contiguous memory. - */ -XZ_EXTERN enum xz_ret xz_dec_microlzma_run(struct xz_dec_microlzma *s, - struct xz_buf *b); - -/** - * xz_dec_microlzma_end() - Free the memory allocated for the decoder state - * @s: Decoder state allocated using xz_dec_microlzma_alloc(). - * If s is NULL, this function does nothing. - */ -XZ_EXTERN void xz_dec_microlzma_end(struct xz_dec_microlzma *s); - -/* - * Standalone build (userspace build or in-kernel build for boot time use) - * needs a CRC32 implementation. For normal in-kernel use, kernel's own - * CRC32 module is used instead, and users of this module don't need to - * care about the functions below. - */ -#ifndef XZ_INTERNAL_CRC32 -# ifdef __KERNEL__ -# define XZ_INTERNAL_CRC32 0 -# else -# define XZ_INTERNAL_CRC32 1 -# endif -#endif - -/* - * If CRC64 support has been enabled with XZ_USE_CRC64, a CRC64 - * implementation is needed too. - */ -#ifndef XZ_USE_CRC64 -# undef XZ_INTERNAL_CRC64 -# define XZ_INTERNAL_CRC64 0 -#endif -#ifndef XZ_INTERNAL_CRC64 -# ifdef __KERNEL__ -# error Using CRC64 in the kernel has not been implemented. -# else -# define XZ_INTERNAL_CRC64 1 -# endif -#endif - -#if XZ_INTERNAL_CRC32 -/* - * This must be called before any other xz_* function to initialize - * the CRC32 lookup table. - */ -XZ_EXTERN void xz_crc32_init(void); - -/* - * Update CRC32 value using the polynomial from IEEE-802.3. To start a new - * calculation, the third argument must be zero. To continue the calculation, - * the previously returned value is passed as the third argument. - */ -XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc); -#endif - -#if XZ_INTERNAL_CRC64 -/* - * This must be called before any other xz_* function (except xz_crc32_init()) - * to initialize the CRC64 lookup table. - */ -XZ_EXTERN void xz_crc64_init(void); - -/* - * Update CRC64 value using the polynomial from ECMA-182. To start a new - * calculation, the third argument must be zero. To continue the calculation, - * the previously returned value is passed as the third argument. - */ -XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc); -#endif - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/android/app/src/main/cpp/xz/xz_config.h b/android/app/src/main/cpp/xz/xz_config.h deleted file mode 100644 index d7d4031da..000000000 --- a/android/app/src/main/cpp/xz/xz_config.h +++ /dev/null @@ -1,138 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -/* - * Private includes and definitions for userspace use of XZ Embedded - * - * Author: Lasse Collin - */ - -#ifndef XZ_CONFIG_H -#define XZ_CONFIG_H - -/* Uncomment to enable building of xz_dec_catrun(). */ -/* #define XZ_DEC_CONCATENATED */ - -/* Uncomment to enable CRC64 support. */ -/* #define XZ_USE_CRC64 */ - -/* Uncomment as needed to enable BCJ filter decoders. */ -/* #define XZ_DEC_X86 */ -/* #define XZ_DEC_ARM */ -/* #define XZ_DEC_ARMTHUMB */ -/* #define XZ_DEC_ARM64 */ -/* #define XZ_DEC_RISCV */ -/* #define XZ_DEC_POWERPC */ -/* #define XZ_DEC_IA64 */ -/* #define XZ_DEC_SPARC */ - -/* - * Visual Studio 2013 update 2 supports only __inline, not inline. - * MSVC v19.0 / VS 2015 and newer support both. - */ -#if defined(_MSC_VER) && _MSC_VER < 1900 && !defined(inline) -# define inline __inline -#endif - -#include -#include -#include - -#include "xz.h" - -#define kmalloc(size, flags) malloc(size) -#define kfree(ptr) free(ptr) -#define vmalloc(size) malloc(size) -#define vfree(ptr) free(ptr) - -#define memeq(a, b, size) (memcmp(a, b, size) == 0) -#define memzero(buf, size) memset(buf, 0, size) - -#ifndef min -# define min(x, y) ((x) < (y) ? (x) : (y)) -#endif -#define min_t(type, x, y) min(x, y) - -#ifndef fallthrough -# if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311 -# define fallthrough [[fallthrough]] -# elif (defined(__GNUC__) && __GNUC__ >= 7) \ - || (defined(__clang_major__) && __clang_major__ >= 10) -# define fallthrough __attribute__((__fallthrough__)) -# else -# define fallthrough do {} while (0) -# endif -#endif - -/* - * Some functions have been marked with __always_inline to keep the - * performance reasonable even when the compiler is optimizing for - * small code size. You may be able to save a few bytes by #defining - * __always_inline to plain inline, but don't complain if the code - * becomes slow. - * - * NOTE: System headers on GNU/Linux may #define this macro already, - * so if you want to change it, you need to #undef it first. - */ -#ifndef __always_inline -# ifdef __GNUC__ -# define __always_inline \ - inline __attribute__((__always_inline__)) -# else -# define __always_inline inline -# endif -#endif - -/* Inline functions to access unaligned unsigned 32-bit integers */ -#ifndef get_unaligned_le32 -static inline uint32_t get_unaligned_le32(const uint8_t *buf) -{ - return (uint32_t)buf[0] - | ((uint32_t)buf[1] << 8) - | ((uint32_t)buf[2] << 16) - | ((uint32_t)buf[3] << 24); -} -#endif - -#ifndef get_unaligned_be32 -static inline uint32_t get_unaligned_be32(const uint8_t *buf) -{ - return (uint32_t)((uint32_t)buf[0] << 24) - | ((uint32_t)buf[1] << 16) - | ((uint32_t)buf[2] << 8) - | (uint32_t)buf[3]; -} -#endif - -#ifndef put_unaligned_le32 -static inline void put_unaligned_le32(uint32_t val, uint8_t *buf) -{ - buf[0] = (uint8_t)val; - buf[1] = (uint8_t)(val >> 8); - buf[2] = (uint8_t)(val >> 16); - buf[3] = (uint8_t)(val >> 24); -} -#endif - -#ifndef put_unaligned_be32 -static inline void put_unaligned_be32(uint32_t val, uint8_t *buf) -{ - buf[0] = (uint8_t)(val >> 24); - buf[1] = (uint8_t)(val >> 16); - buf[2] = (uint8_t)(val >> 8); - buf[3] = (uint8_t)val; -} -#endif - -/* - * To keep things simpler, use the generic unaligned methods also for - * aligned access. The only place where performance could matter is - * SHA-256 but files using SHA-256 aren't common. - */ -#ifndef get_le32 -# define get_le32 get_unaligned_le32 -#endif -#ifndef get_be32 -# define get_be32 get_unaligned_be32 -#endif - -#endif diff --git a/android/app/src/main/cpp/xz/xz_crc32.c b/android/app/src/main/cpp/xz/xz_crc32.c deleted file mode 100644 index effdf34ec..000000000 --- a/android/app/src/main/cpp/xz/xz_crc32.c +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * CRC32 using the polynomial from IEEE-802.3 - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -/* - * This is not the fastest implementation, but it is pretty compact. - * The fastest versions of xz_crc32() on modern CPUs without hardware - * accelerated CRC instruction are 3-5 times as fast as this version, - * but they are bigger and use more memory for the lookup table. - */ - -#include "xz_private.h" - -/* - * STATIC_RW_DATA is used in the pre-boot environment on some architectures. - * See for details. - */ -#ifndef STATIC_RW_DATA -# define STATIC_RW_DATA static -#endif - -STATIC_RW_DATA uint32_t xz_crc32_table[256]; - -XZ_EXTERN void xz_crc32_init(void) -{ - const uint32_t poly = 0xEDB88320; - - uint32_t i; - uint32_t j; - uint32_t r; - - for (i = 0; i < 256; ++i) { - r = i; - for (j = 0; j < 8; ++j) - r = (r >> 1) ^ (poly & ~((r & 1) - 1)); - - xz_crc32_table[i] = r; - } - - return; -} - -XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc) -{ - crc = ~crc; - - while (size != 0) { - crc = xz_crc32_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8); - --size; - } - - return ~crc; -} diff --git a/android/app/src/main/cpp/xz/xz_crc64.c b/android/app/src/main/cpp/xz/xz_crc64.c deleted file mode 100644 index 20049ea55..000000000 --- a/android/app/src/main/cpp/xz/xz_crc64.c +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * CRC64 using the polynomial from ECMA-182 - * - * This file is similar to xz_crc32.c. See the comments there. - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -#include "xz_private.h" - -#ifndef STATIC_RW_DATA -# define STATIC_RW_DATA static -#endif - -STATIC_RW_DATA uint64_t xz_crc64_table[256]; - -XZ_EXTERN void xz_crc64_init(void) -{ - /* - * The ULL suffix is needed for -std=gnu89 compatibility - * on 32-bit platforms. - */ - const uint64_t poly = 0xC96C5795D7870F42ULL; - - uint32_t i; - uint32_t j; - uint64_t r; - - for (i = 0; i < 256; ++i) { - r = i; - for (j = 0; j < 8; ++j) - r = (r >> 1) ^ (poly & ~((r & 1) - 1)); - - xz_crc64_table[i] = r; - } - - return; -} - -XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc) -{ - crc = ~crc; - - while (size != 0) { - crc = xz_crc64_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8); - --size; - } - - return ~crc; -} diff --git a/android/app/src/main/cpp/xz/xz_dec_bcj.c b/android/app/src/main/cpp/xz/xz_dec_bcj.c deleted file mode 100644 index 42d7f2687..000000000 --- a/android/app/src/main/cpp/xz/xz_dec_bcj.c +++ /dev/null @@ -1,738 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * Branch/Call/Jump (BCJ) filter decoders - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -#include "xz_private.h" - -/* - * The rest of the file is inside this ifdef. It makes things a little more - * convenient when building without support for any BCJ filters. - */ -#ifdef XZ_DEC_BCJ - -struct xz_dec_bcj { - /* Type of the BCJ filter being used */ - enum { - BCJ_X86 = 4, /* x86 or x86-64 */ - BCJ_POWERPC = 5, /* Big endian only */ - BCJ_IA64 = 6, /* Big or little endian */ - BCJ_ARM = 7, /* Little endian only */ - BCJ_ARMTHUMB = 8, /* Little endian only */ - BCJ_SPARC = 9, /* Big or little endian */ - BCJ_ARM64 = 10, /* AArch64 */ - BCJ_RISCV = 11 /* RV32GQC_Zfh, RV64GQC_Zfh */ - } type; - - /* - * Return value of the next filter in the chain. We need to preserve - * this information across calls, because we must not call the next - * filter anymore once it has returned XZ_STREAM_END. - */ - enum xz_ret ret; - - /* True if we are operating in single-call mode. */ - bool single_call; - - /* - * Absolute position relative to the beginning of the uncompressed - * data (in a single .xz Block). We care only about the lowest 32 - * bits so this doesn't need to be uint64_t even with big files. - */ - uint32_t pos; - - /* x86 filter state */ - uint32_t x86_prev_mask; - - /* Temporary space to hold the variables from struct xz_buf */ - uint8_t *out; - size_t out_pos; - size_t out_size; - - struct { - /* Amount of already filtered data in the beginning of buf */ - size_t filtered; - - /* Total amount of data currently stored in buf */ - size_t size; - - /* - * Buffer to hold a mix of filtered and unfiltered data. This - * needs to be big enough to hold Alignment + 2 * Look-ahead: - * - * Type Alignment Look-ahead - * x86 1 4 - * PowerPC 4 0 - * IA-64 16 0 - * ARM 4 0 - * ARM-Thumb 2 2 - * SPARC 4 0 - */ - uint8_t buf[16]; - } temp; -}; - -#ifdef XZ_DEC_X86 -/* - * This is used to test the most significant byte of a memory address - * in an x86 instruction. - */ -static inline int bcj_x86_test_msbyte(uint8_t b) -{ - return b == 0x00 || b == 0xFF; -} - -static size_t bcj_x86(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - static const bool mask_to_allowed_status[8] - = { true, true, true, false, true, false, false, false }; - - static const uint8_t mask_to_bit_num[8] = { 0, 1, 2, 2, 3, 3, 3, 3 }; - - size_t i; - size_t prev_pos = (size_t)-1; - uint32_t prev_mask = s->x86_prev_mask; - uint32_t src; - uint32_t dest; - uint32_t j; - uint8_t b; - - if (size <= 4) - return 0; - - size -= 4; - for (i = 0; i < size; ++i) { - if ((buf[i] & 0xFE) != 0xE8) - continue; - - prev_pos = i - prev_pos; - if (prev_pos > 3) { - prev_mask = 0; - } else { - prev_mask = (prev_mask << (prev_pos - 1)) & 7; - if (prev_mask != 0) { - b = buf[i + 4 - mask_to_bit_num[prev_mask]]; - if (!mask_to_allowed_status[prev_mask] - || bcj_x86_test_msbyte(b)) { - prev_pos = i; - prev_mask = (prev_mask << 1) | 1; - continue; - } - } - } - - prev_pos = i; - - if (bcj_x86_test_msbyte(buf[i + 4])) { - src = get_unaligned_le32(buf + i + 1); - while (true) { - dest = src - (s->pos + (uint32_t)i + 5); - if (prev_mask == 0) - break; - - j = mask_to_bit_num[prev_mask] * 8; - b = (uint8_t)(dest >> (24 - j)); - if (!bcj_x86_test_msbyte(b)) - break; - - src = dest ^ (((uint32_t)1 << (32 - j)) - 1); - } - - dest &= 0x01FFFFFF; - dest |= (uint32_t)0 - (dest & 0x01000000); - put_unaligned_le32(dest, buf + i + 1); - i += 4; - } else { - prev_mask = (prev_mask << 1) | 1; - } - } - - prev_pos = i - prev_pos; - s->x86_prev_mask = prev_pos > 3 ? 0 : prev_mask << (prev_pos - 1); - return i; -} -#endif - -#ifdef XZ_DEC_POWERPC -static size_t bcj_powerpc(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t instr; - - size &= ~(size_t)3; - - for (i = 0; i < size; i += 4) { - instr = get_unaligned_be32(buf + i); - if ((instr & 0xFC000003) == 0x48000001) { - instr &= 0x03FFFFFC; - instr -= s->pos + (uint32_t)i; - instr &= 0x03FFFFFC; - instr |= 0x48000001; - put_unaligned_be32(instr, buf + i); - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_IA64 -static size_t bcj_ia64(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - static const uint8_t branch_table[32] = { - 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, - 4, 4, 6, 6, 0, 0, 7, 7, - 4, 4, 0, 0, 4, 4, 0, 0 - }; - - /* - * The local variables take a little bit stack space, but it's less - * than what LZMA2 decoder takes, so it doesn't make sense to reduce - * stack usage here without doing that for the LZMA2 decoder too. - */ - - /* Loop counters */ - size_t i; - size_t j; - - /* Instruction slot (0, 1, or 2) in the 128-bit instruction word */ - uint32_t slot; - - /* Bitwise offset of the instruction indicated by slot */ - uint32_t bit_pos; - - /* bit_pos split into byte and bit parts */ - uint32_t byte_pos; - uint32_t bit_res; - - /* Address part of an instruction */ - uint32_t addr; - - /* Mask used to detect which instructions to convert */ - uint32_t mask; - - /* 41-bit instruction stored somewhere in the lowest 48 bits */ - uint64_t instr; - - /* Instruction normalized with bit_res for easier manipulation */ - uint64_t norm; - - size &= ~(size_t)15; - - for (i = 0; i < size; i += 16) { - mask = branch_table[buf[i] & 0x1F]; - for (slot = 0, bit_pos = 5; slot < 3; ++slot, bit_pos += 41) { - if (((mask >> slot) & 1) == 0) - continue; - - byte_pos = bit_pos >> 3; - bit_res = bit_pos & 7; - instr = 0; - for (j = 0; j < 6; ++j) - instr |= (uint64_t)(buf[i + j + byte_pos]) - << (8 * j); - - norm = instr >> bit_res; - - if (((norm >> 37) & 0x0F) == 0x05 - && ((norm >> 9) & 0x07) == 0) { - addr = (norm >> 13) & 0x0FFFFF; - addr |= ((uint32_t)(norm >> 36) & 1) << 20; - addr <<= 4; - addr -= s->pos + (uint32_t)i; - addr >>= 4; - - norm &= ~((uint64_t)0x8FFFFF << 13); - norm |= (uint64_t)(addr & 0x0FFFFF) << 13; - norm |= (uint64_t)(addr & 0x100000) - << (36 - 20); - - instr &= (1 << bit_res) - 1; - instr |= norm << bit_res; - - for (j = 0; j < 6; j++) - buf[i + j + byte_pos] - = (uint8_t)(instr >> (8 * j)); - } - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_ARM -static size_t bcj_arm(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t addr; - - size &= ~(size_t)3; - - for (i = 0; i < size; i += 4) { - if (buf[i + 3] == 0xEB) { - addr = (uint32_t)buf[i] | ((uint32_t)buf[i + 1] << 8) - | ((uint32_t)buf[i + 2] << 16); - addr <<= 2; - addr -= s->pos + (uint32_t)i + 8; - addr >>= 2; - buf[i] = (uint8_t)addr; - buf[i + 1] = (uint8_t)(addr >> 8); - buf[i + 2] = (uint8_t)(addr >> 16); - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_ARMTHUMB -static size_t bcj_armthumb(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t addr; - - if (size < 4) - return 0; - - size -= 4; - - for (i = 0; i <= size; i += 2) { - if ((buf[i + 1] & 0xF8) == 0xF0 - && (buf[i + 3] & 0xF8) == 0xF8) { - addr = (((uint32_t)buf[i + 1] & 0x07) << 19) - | ((uint32_t)buf[i] << 11) - | (((uint32_t)buf[i + 3] & 0x07) << 8) - | (uint32_t)buf[i + 2]; - addr <<= 1; - addr -= s->pos + (uint32_t)i + 4; - addr >>= 1; - buf[i + 1] = (uint8_t)(0xF0 | ((addr >> 19) & 0x07)); - buf[i] = (uint8_t)(addr >> 11); - buf[i + 3] = (uint8_t)(0xF8 | ((addr >> 8) & 0x07)); - buf[i + 2] = (uint8_t)addr; - i += 2; - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_SPARC -static size_t bcj_sparc(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t instr; - - size &= ~(size_t)3; - - for (i = 0; i < size; i += 4) { - instr = get_unaligned_be32(buf + i); - if ((instr >> 22) == 0x100 || (instr >> 22) == 0x1FF) { - instr <<= 2; - instr -= s->pos + (uint32_t)i; - instr >>= 2; - instr = ((uint32_t)0x40000000 - (instr & 0x400000)) - | 0x40000000 | (instr & 0x3FFFFF); - put_unaligned_be32(instr, buf + i); - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_ARM64 -static size_t bcj_arm64(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t instr; - uint32_t addr; - - size &= ~(size_t)3; - - for (i = 0; i < size; i += 4) { - instr = get_unaligned_le32(buf + i); - - if ((instr >> 26) == 0x25) { - /* BL instruction */ - addr = instr - ((s->pos + (uint32_t)i) >> 2); - instr = 0x94000000 | (addr & 0x03FFFFFF); - put_unaligned_le32(instr, buf + i); - - } else if ((instr & 0x9F000000) == 0x90000000) { - /* ADRP instruction */ - addr = ((instr >> 29) & 3) | ((instr >> 3) & 0x1FFFFC); - - /* Only convert values in the range +/-512 MiB. */ - if ((addr + 0x020000) & 0x1C0000) - continue; - - addr -= (s->pos + (uint32_t)i) >> 12; - - instr &= 0x9000001F; - instr |= (addr & 3) << 29; - instr |= (addr & 0x03FFFC) << 3; - instr |= (0U - (addr & 0x020000)) & 0xE00000; - - put_unaligned_le32(instr, buf + i); - } - } - - return i; -} -#endif - -#ifdef XZ_DEC_RISCV -static size_t bcj_riscv(struct xz_dec_bcj *s, uint8_t *buf, size_t size) -{ - size_t i; - uint32_t b1; - uint32_t b2; - uint32_t b3; - uint32_t instr; - uint32_t instr2; - uint32_t instr2_rs1; - uint32_t addr; - - if (size < 8) - return 0; - - size -= 8; - - for (i = 0; i <= size; i += 2) { - instr = buf[i]; - - if (instr == 0xEF) { - /* JAL */ - b1 = buf[i + 1]; - if ((b1 & 0x0D) != 0) - continue; - - b2 = buf[i + 2]; - b3 = buf[i + 3]; - - addr = ((b1 & 0xF0) << 13) | (b2 << 9) | (b3 << 1); - addr -= s->pos + (uint32_t)i; - - buf[i + 1] = (uint8_t)((b1 & 0x0F) - | ((addr >> 8) & 0xF0)); - - buf[i + 2] = (uint8_t)(((addr >> 16) & 0x0F) - | ((addr >> 7) & 0x10) - | ((addr << 4) & 0xE0)); - - buf[i + 3] = (uint8_t)(((addr >> 4) & 0x7F) - | ((addr >> 13) & 0x80)); - - i += 4 - 2; - - } else if ((instr & 0x7F) == 0x17) { - /* AUIPC */ - instr |= (uint32_t)buf[i + 1] << 8; - instr |= (uint32_t)buf[i + 2] << 16; - instr |= (uint32_t)buf[i + 3] << 24; - - if (instr & 0xE80) { - /* AUIPC's rd doesn't equal x0 or x2. */ - instr2 = get_unaligned_le32(buf + i + 4); - - if (((instr << 8) ^ (instr2 - 3)) & 0xF8003) { - i += 6 - 2; - continue; - } - - addr = (instr & 0xFFFFF000) + (instr2 >> 20); - - instr = 0x17 | (2 << 7) | (instr2 << 12); - instr2 = addr; - } else { - /* AUIPC's rd equals x0 or x2. */ - instr2_rs1 = instr >> 27; - - if ((uint32_t)((instr - 0x3117) << 18) - >= (instr2_rs1 & 0x1D)) { - i += 4 - 2; - continue; - } - - addr = get_unaligned_be32(buf + i + 4); - addr -= s->pos + (uint32_t)i; - - instr2 = (instr >> 12) | (addr << 20); - - instr = 0x17 | (instr2_rs1 << 7) - | ((addr + 0x800) & 0xFFFFF000); - } - - put_unaligned_le32(instr, buf + i); - put_unaligned_le32(instr2, buf + i + 4); - - i += 8 - 2; - } - } - - return i; -} -#endif - -/* - * Apply the selected BCJ filter. Update *pos and s->pos to match the amount - * of data that got filtered. - * - * NOTE: This is implemented as a switch statement to avoid using function - * pointers, which could be problematic in the kernel boot code, which must - * avoid pointers to static data (at least on x86). - */ -static void bcj_apply(struct xz_dec_bcj *s, - uint8_t *buf, size_t *pos, size_t size) -{ - size_t filtered; - - buf += *pos; - size -= *pos; - - switch (s->type) { -#ifdef XZ_DEC_X86 - case BCJ_X86: - filtered = bcj_x86(s, buf, size); - break; -#endif -#ifdef XZ_DEC_POWERPC - case BCJ_POWERPC: - filtered = bcj_powerpc(s, buf, size); - break; -#endif -#ifdef XZ_DEC_IA64 - case BCJ_IA64: - filtered = bcj_ia64(s, buf, size); - break; -#endif -#ifdef XZ_DEC_ARM - case BCJ_ARM: - filtered = bcj_arm(s, buf, size); - break; -#endif -#ifdef XZ_DEC_ARMTHUMB - case BCJ_ARMTHUMB: - filtered = bcj_armthumb(s, buf, size); - break; -#endif -#ifdef XZ_DEC_SPARC - case BCJ_SPARC: - filtered = bcj_sparc(s, buf, size); - break; -#endif -#ifdef XZ_DEC_ARM64 - case BCJ_ARM64: - filtered = bcj_arm64(s, buf, size); - break; -#endif -#ifdef XZ_DEC_RISCV - case BCJ_RISCV: - filtered = bcj_riscv(s, buf, size); - break; -#endif - default: - /* Never reached but silence compiler warnings. */ - filtered = 0; - break; - } - - *pos += filtered; - s->pos += filtered; -} - -/* - * Flush pending filtered data from temp to the output buffer. - * Move the remaining mixture of possibly filtered and unfiltered - * data to the beginning of temp. - */ -static void bcj_flush(struct xz_dec_bcj *s, struct xz_buf *b) -{ - size_t copy_size; - - copy_size = min_t(size_t, s->temp.filtered, b->out_size - b->out_pos); - memcpy(b->out + b->out_pos, s->temp.buf, copy_size); - b->out_pos += copy_size; - - s->temp.filtered -= copy_size; - s->temp.size -= copy_size; - memmove(s->temp.buf, s->temp.buf + copy_size, s->temp.size); -} - -/* - * The BCJ filter functions are primitive in sense that they process the - * data in chunks of 1-16 bytes. To hide this issue, this function does - * some buffering. - */ -XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s, - struct xz_dec_lzma2 *lzma2, - struct xz_buf *b) -{ - size_t out_start; - - /* - * Flush pending already filtered data to the output buffer. Return - * immediately if we couldn't flush everything, or if the next - * filter in the chain had already returned XZ_STREAM_END. - */ - if (s->temp.filtered > 0) { - bcj_flush(s, b); - if (s->temp.filtered > 0) - return XZ_OK; - - if (s->ret == XZ_STREAM_END) - return XZ_STREAM_END; - } - - /* - * If we have more output space than what is currently pending in - * temp, copy the unfiltered data from temp to the output buffer - * and try to fill the output buffer by decoding more data from the - * next filter in the chain. Apply the BCJ filter on the new data - * in the output buffer. If everything cannot be filtered, copy it - * to temp and rewind the output buffer position accordingly. - * - * This needs to be always run when temp.size == 0 to handle a special - * case where the output buffer is full and the next filter has no - * more output coming but hasn't returned XZ_STREAM_END yet. - */ - if (s->temp.size < b->out_size - b->out_pos || s->temp.size == 0) { - out_start = b->out_pos; - memcpy(b->out + b->out_pos, s->temp.buf, s->temp.size); - b->out_pos += s->temp.size; - - s->ret = xz_dec_lzma2_run(lzma2, b); - if (s->ret != XZ_STREAM_END - && (s->ret != XZ_OK || s->single_call)) - return s->ret; - - bcj_apply(s, b->out, &out_start, b->out_pos); - - /* - * As an exception, if the next filter returned XZ_STREAM_END, - * we can do that too, since the last few bytes that remain - * unfiltered are meant to remain unfiltered. - */ - if (s->ret == XZ_STREAM_END) - return XZ_STREAM_END; - - s->temp.size = b->out_pos - out_start; - b->out_pos -= s->temp.size; - memcpy(s->temp.buf, b->out + b->out_pos, s->temp.size); - - /* - * If there wasn't enough input to the next filter to fill - * the output buffer with unfiltered data, there's no point - * to try decoding more data to temp. - */ - if (b->out_pos + s->temp.size < b->out_size) - return XZ_OK; - } - - /* - * We have unfiltered data in temp. If the output buffer isn't full - * yet, try to fill the temp buffer by decoding more data from the - * next filter. Apply the BCJ filter on temp. Then we hopefully can - * fill the actual output buffer by copying filtered data from temp. - * A mix of filtered and unfiltered data may be left in temp; it will - * be taken care on the next call to this function. - */ - if (b->out_pos < b->out_size) { - /* Make b->out{,_pos,_size} temporarily point to s->temp. */ - s->out = b->out; - s->out_pos = b->out_pos; - s->out_size = b->out_size; - b->out = s->temp.buf; - b->out_pos = s->temp.size; - b->out_size = sizeof(s->temp.buf); - - s->ret = xz_dec_lzma2_run(lzma2, b); - - s->temp.size = b->out_pos; - b->out = s->out; - b->out_pos = s->out_pos; - b->out_size = s->out_size; - - if (s->ret != XZ_OK && s->ret != XZ_STREAM_END) - return s->ret; - - bcj_apply(s, s->temp.buf, &s->temp.filtered, s->temp.size); - - /* - * If the next filter returned XZ_STREAM_END, we mark that - * everything is filtered, since the last unfiltered bytes - * of the stream are meant to be left as is. - */ - if (s->ret == XZ_STREAM_END) - s->temp.filtered = s->temp.size; - - bcj_flush(s, b); - if (s->temp.filtered > 0) - return XZ_OK; - } - - return s->ret; -} - -XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call) -{ - struct xz_dec_bcj *s = kmalloc(sizeof(*s), GFP_KERNEL); - if (s != NULL) - s->single_call = single_call; - - return s; -} - -XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id) -{ - switch (id) { -#ifdef XZ_DEC_X86 - case BCJ_X86: -#endif -#ifdef XZ_DEC_POWERPC - case BCJ_POWERPC: -#endif -#ifdef XZ_DEC_IA64 - case BCJ_IA64: -#endif -#ifdef XZ_DEC_ARM - case BCJ_ARM: -#endif -#ifdef XZ_DEC_ARMTHUMB - case BCJ_ARMTHUMB: -#endif -#ifdef XZ_DEC_SPARC - case BCJ_SPARC: -#endif -#ifdef XZ_DEC_ARM64 - case BCJ_ARM64: -#endif -#ifdef XZ_DEC_RISCV - case BCJ_RISCV: -#endif - break; - - default: - /* Unsupported Filter ID */ - return XZ_OPTIONS_ERROR; - } - - s->type = id; - s->ret = XZ_OK; - s->pos = 0; - s->x86_prev_mask = 0; - s->temp.filtered = 0; - s->temp.size = 0; - - return XZ_OK; -} - -#endif diff --git a/android/app/src/main/cpp/xz/xz_dec_lzma2.c b/android/app/src/main/cpp/xz/xz_dec_lzma2.c deleted file mode 100644 index 475c378e2..000000000 --- a/android/app/src/main/cpp/xz/xz_dec_lzma2.c +++ /dev/null @@ -1,1345 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * LZMA2 decoder - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -#include "xz_private.h" -#include "xz_lzma2.h" - -/* - * Range decoder initialization eats the first five bytes of each LZMA chunk. - */ -#define RC_INIT_BYTES 5 - -/* - * Minimum number of usable input buffer to safely decode one LZMA symbol. - * The worst case is that we decode 22 bits using probabilities and 26 - * direct bits. This may decode at maximum of 20 bytes of input. However, - * lzma_main() does an extra normalization before returning, thus we - * need to put 21 here. - */ -#define LZMA_IN_REQUIRED 21 - -/* - * Dictionary (history buffer) - * - * These are always true: - * start <= pos <= full <= end - * pos <= limit <= end - * - * In multi-call mode, also these are true: - * end == size - * size <= size_max - * allocated <= size - * - * Most of these variables are size_t to support single-call mode, - * in which the dictionary variables address the actual output - * buffer directly. - */ -struct dictionary { - /* Beginning of the history buffer */ - uint8_t *buf; - - /* Old position in buf (before decoding more data) */ - size_t start; - - /* Position in buf */ - size_t pos; - - /* - * How full dictionary is. This is used to detect corrupt input that - * would read beyond the beginning of the uncompressed stream. - */ - size_t full; - - /* Write limit; we don't write to buf[limit] or later bytes. */ - size_t limit; - - /* - * End of the dictionary buffer. In multi-call mode, this is - * the same as the dictionary size. In single-call mode, this - * indicates the size of the output buffer. - */ - size_t end; - - /* - * Size of the dictionary as specified in Block Header. This is used - * together with "full" to detect corrupt input that would make us - * read beyond the beginning of the uncompressed stream. - */ - uint32_t size; - - /* - * Maximum allowed dictionary size in multi-call mode. - * This is ignored in single-call mode. - */ - uint32_t size_max; - - /* - * Amount of memory currently allocated for the dictionary. - * This is used only with XZ_DYNALLOC. (With XZ_PREALLOC, - * size_max is always the same as the allocated size.) - */ - uint32_t allocated; - - /* Operation mode */ - enum xz_mode mode; -}; - -/* Range decoder */ -struct rc_dec { - uint32_t range; - uint32_t code; - - /* - * Number of initializing bytes remaining to be read - * by rc_read_init(). - */ - uint32_t init_bytes_left; - - /* - * Buffer from which we read our input. It can be either - * temp.buf or the caller-provided input buffer. - */ - const uint8_t *in; - size_t in_pos; - size_t in_limit; -}; - -/* Probabilities for a length decoder. */ -struct lzma_len_dec { - /* Probability of match length being at least 10 */ - uint16_t choice; - - /* Probability of match length being at least 18 */ - uint16_t choice2; - - /* Probabilities for match lengths 2-9 */ - uint16_t low[POS_STATES_MAX][LEN_LOW_SYMBOLS]; - - /* Probabilities for match lengths 10-17 */ - uint16_t mid[POS_STATES_MAX][LEN_MID_SYMBOLS]; - - /* Probabilities for match lengths 18-273 */ - uint16_t high[LEN_HIGH_SYMBOLS]; -}; - -struct lzma_dec { - /* Distances of latest four matches */ - uint32_t rep0; - uint32_t rep1; - uint32_t rep2; - uint32_t rep3; - - /* Types of the most recently seen LZMA symbols */ - enum lzma_state state; - - /* - * Length of a match. This is updated so that dict_repeat can - * be called again to finish repeating the whole match. - */ - uint32_t len; - - /* - * LZMA properties or related bit masks (number of literal - * context bits, a mask derived from the number of literal - * position bits, and a mask derived from the number - * position bits) - */ - uint32_t lc; - uint32_t literal_pos_mask; /* (1 << lp) - 1 */ - uint32_t pos_mask; /* (1 << pb) - 1 */ - - /* If 1, it's a match. Otherwise it's a single 8-bit literal. */ - uint16_t is_match[STATES][POS_STATES_MAX]; - - /* If 1, it's a repeated match. The distance is one of rep0 .. rep3. */ - uint16_t is_rep[STATES]; - - /* - * If 0, distance of a repeated match is rep0. - * Otherwise check is_rep1. - */ - uint16_t is_rep0[STATES]; - - /* - * If 0, distance of a repeated match is rep1. - * Otherwise check is_rep2. - */ - uint16_t is_rep1[STATES]; - - /* If 0, distance of a repeated match is rep2. Otherwise it is rep3. */ - uint16_t is_rep2[STATES]; - - /* - * If 1, the repeated match has length of one byte. Otherwise - * the length is decoded from rep_len_decoder. - */ - uint16_t is_rep0_long[STATES][POS_STATES_MAX]; - - /* - * Probability tree for the highest two bits of the match - * distance. There is a separate probability tree for match - * lengths of 2 (i.e. MATCH_LEN_MIN), 3, 4, and [5, 273]. - */ - uint16_t dist_slot[DIST_STATES][DIST_SLOTS]; - - /* - * Probability trees for additional bits for match distance - * when the distance is in the range [4, 127]. - */ - uint16_t dist_special[FULL_DISTANCES - DIST_MODEL_END]; - - /* - * Probability tree for the lowest four bits of a match - * distance that is equal to or greater than 128. - */ - uint16_t dist_align[ALIGN_SIZE]; - - /* Length of a normal match */ - struct lzma_len_dec match_len_dec; - - /* Length of a repeated match */ - struct lzma_len_dec rep_len_dec; - - /* Probabilities of literals */ - uint16_t literal[LITERAL_CODERS_MAX][LITERAL_CODER_SIZE]; -}; - -struct lzma2_dec { - /* Position in xz_dec_lzma2_run(). */ - enum lzma2_seq { - SEQ_CONTROL, - SEQ_UNCOMPRESSED_1, - SEQ_UNCOMPRESSED_2, - SEQ_COMPRESSED_0, - SEQ_COMPRESSED_1, - SEQ_PROPERTIES, - SEQ_LZMA_PREPARE, - SEQ_LZMA_RUN, - SEQ_COPY - } sequence; - - /* Next position after decoding the compressed size of the chunk. */ - enum lzma2_seq next_sequence; - - /* Uncompressed size of LZMA chunk (2 MiB at maximum) */ - uint32_t uncompressed; - - /* - * Compressed size of LZMA chunk or compressed/uncompressed - * size of uncompressed chunk (64 KiB at maximum) - */ - uint32_t compressed; - - /* - * True if dictionary reset is needed. This is false before - * the first chunk (LZMA or uncompressed). - */ - bool need_dict_reset; - - /* - * True if new LZMA properties are needed. This is false - * before the first LZMA chunk. - */ - bool need_props; - -#ifdef XZ_DEC_MICROLZMA - bool pedantic_microlzma; -#endif -}; - -struct xz_dec_lzma2 { - /* - * The order below is important on x86 to reduce code size and - * it shouldn't hurt on other platforms. Everything up to and - * including lzma.pos_mask are in the first 128 bytes on x86-32, - * which allows using smaller instructions to access those - * variables. On x86-64, fewer variables fit into the first 128 - * bytes, but this is still the best order without sacrificing - * the readability by splitting the structures. - */ - struct rc_dec rc; - struct dictionary dict; - struct lzma2_dec lzma2; - struct lzma_dec lzma; - - /* - * Temporary buffer which holds small number of input bytes between - * decoder calls. See lzma2_lzma() for details. - */ - struct { - uint32_t size; - uint8_t buf[3 * LZMA_IN_REQUIRED]; - } temp; -}; - -/************** - * Dictionary * - **************/ - -/* - * Reset the dictionary state. When in single-call mode, set up the beginning - * of the dictionary to point to the actual output buffer. - */ -static void dict_reset(struct dictionary *dict, struct xz_buf *b) -{ - if (DEC_IS_SINGLE(dict->mode)) { - dict->buf = b->out + b->out_pos; - dict->end = b->out_size - b->out_pos; - } - - dict->start = 0; - dict->pos = 0; - dict->limit = 0; - dict->full = 0; -} - -/* Set dictionary write limit */ -static void dict_limit(struct dictionary *dict, size_t out_max) -{ - if (dict->end - dict->pos <= out_max) - dict->limit = dict->end; - else - dict->limit = dict->pos + out_max; -} - -/* Return true if at least one byte can be written into the dictionary. */ -static inline bool dict_has_space(const struct dictionary *dict) -{ - return dict->pos < dict->limit; -} - -/* - * Get a byte from the dictionary at the given distance. The distance is - * assumed to valid, or as a special case, zero when the dictionary is - * still empty. This special case is needed for single-call decoding to - * avoid writing a '\0' to the end of the destination buffer. - */ -static inline uint32_t dict_get(const struct dictionary *dict, uint32_t dist) -{ - size_t offset = dict->pos - dist - 1; - - if (dist >= dict->pos) - offset += dict->end; - - return dict->full > 0 ? dict->buf[offset] : 0; -} - -/* - * Put one byte into the dictionary. It is assumed that there is space for it. - */ -static inline void dict_put(struct dictionary *dict, uint8_t byte) -{ - dict->buf[dict->pos++] = byte; - - if (dict->full < dict->pos) - dict->full = dict->pos; -} - -/* - * Repeat given number of bytes from the given distance. If the distance is - * invalid, false is returned. On success, true is returned and *len is - * updated to indicate how many bytes were left to be repeated. - */ -static bool dict_repeat(struct dictionary *dict, uint32_t *len, uint32_t dist) -{ - size_t back; - uint32_t left; - - if (dist >= dict->full || dist >= dict->size) - return false; - - left = min_t(size_t, dict->limit - dict->pos, *len); - *len -= left; - - back = dict->pos - dist - 1; - if (dist >= dict->pos) - back += dict->end; - - do { - dict->buf[dict->pos++] = dict->buf[back++]; - if (back == dict->end) - back = 0; - } while (--left > 0); - - if (dict->full < dict->pos) - dict->full = dict->pos; - - return true; -} - -/* Copy uncompressed data as is from input to dictionary and output buffers. */ -static void dict_uncompressed(struct dictionary *dict, struct xz_buf *b, - uint32_t *left) -{ - size_t copy_size; - - while (*left > 0 && b->in_pos < b->in_size - && b->out_pos < b->out_size) { - copy_size = min(b->in_size - b->in_pos, - b->out_size - b->out_pos); - if (copy_size > dict->end - dict->pos) - copy_size = dict->end - dict->pos; - if (copy_size > *left) - copy_size = *left; - - *left -= copy_size; - - /* - * If doing in-place decompression in single-call mode and the - * uncompressed size of the file is larger than the caller - * thought (i.e. it is invalid input!), the buffers below may - * overlap and cause undefined behavior with memcpy(). - * With valid inputs memcpy() would be fine here. - */ - memmove(dict->buf + dict->pos, b->in + b->in_pos, copy_size); - dict->pos += copy_size; - - if (dict->full < dict->pos) - dict->full = dict->pos; - - if (DEC_IS_MULTI(dict->mode)) { - if (dict->pos == dict->end) - dict->pos = 0; - - /* - * Like above but for multi-call mode: use memmove() - * to avoid undefined behavior with invalid input. - */ - memmove(b->out + b->out_pos, b->in + b->in_pos, - copy_size); - } - - dict->start = dict->pos; - - b->out_pos += copy_size; - b->in_pos += copy_size; - } -} - -#ifdef XZ_DEC_MICROLZMA -# define DICT_FLUSH_SUPPORTS_SKIPPING true -#else -# define DICT_FLUSH_SUPPORTS_SKIPPING false -#endif - -/* - * Flush pending data from dictionary to b->out. It is assumed that there is - * enough space in b->out. This is guaranteed because caller uses dict_limit() - * before decoding data into the dictionary. - */ -static uint32_t dict_flush(struct dictionary *dict, struct xz_buf *b) -{ - size_t copy_size = dict->pos - dict->start; - - if (DEC_IS_MULTI(dict->mode)) { - if (dict->pos == dict->end) - dict->pos = 0; - - /* - * These buffers cannot overlap even if doing in-place - * decompression because in multi-call mode dict->buf - * has been allocated by us in this file; it's not - * provided by the caller like in single-call mode. - * - * With MicroLZMA, b->out can be NULL to skip bytes that - * the caller doesn't need. This cannot be done with XZ - * because it would break BCJ filters. - */ - if (!DICT_FLUSH_SUPPORTS_SKIPPING || b->out != NULL) - memcpy(b->out + b->out_pos, dict->buf + dict->start, - copy_size); - } - - dict->start = dict->pos; - b->out_pos += copy_size; - return copy_size; -} - -/***************** - * Range decoder * - *****************/ - -/* Reset the range decoder. */ -static void rc_reset(struct rc_dec *rc) -{ - rc->range = (uint32_t)-1; - rc->code = 0; - rc->init_bytes_left = RC_INIT_BYTES; -} - -/* - * Read the first five initial bytes into rc->code if they haven't been - * read already. (Yes, the first byte gets completely ignored.) - */ -static bool rc_read_init(struct rc_dec *rc, struct xz_buf *b) -{ - while (rc->init_bytes_left > 0) { - if (b->in_pos == b->in_size) - return false; - - rc->code = (rc->code << 8) + b->in[b->in_pos++]; - --rc->init_bytes_left; - } - - return true; -} - -/* Return true if there may not be enough input for the next decoding loop. */ -static inline bool rc_limit_exceeded(const struct rc_dec *rc) -{ - return rc->in_pos > rc->in_limit; -} - -/* - * Return true if it is possible (from point of view of range decoder) that - * we have reached the end of the LZMA chunk. - */ -static inline bool rc_is_finished(const struct rc_dec *rc) -{ - return rc->code == 0; -} - -/* Read the next input byte if needed. */ -static __always_inline void rc_normalize(struct rc_dec *rc) -{ - if (rc->range < RC_TOP_VALUE) { - rc->range <<= RC_SHIFT_BITS; - rc->code = (rc->code << RC_SHIFT_BITS) + rc->in[rc->in_pos++]; - } -} - -/* - * Decode one bit. In some versions, this function has been split in three - * functions so that the compiler is supposed to be able to more easily avoid - * an extra branch. In this particular version of the LZMA decoder, this - * doesn't seem to be a good idea (tested with GCC 3.3.6, 3.4.6, and 4.3.3 - * on x86). Using a non-split version results in nicer looking code too. - * - * NOTE: This must return an int. Do not make it return a bool or the speed - * of the code generated by GCC 3.x decreases 10-15 %. (GCC 4.3 doesn't care, - * and it generates 10-20 % faster code than GCC 3.x from this file anyway.) - */ -static __always_inline int rc_bit(struct rc_dec *rc, uint16_t *prob) -{ - uint32_t bound; - int bit; - - rc_normalize(rc); - bound = (rc->range >> RC_BIT_MODEL_TOTAL_BITS) * *prob; - if (rc->code < bound) { - rc->range = bound; - *prob += (RC_BIT_MODEL_TOTAL - *prob) >> RC_MOVE_BITS; - bit = 0; - } else { - rc->range -= bound; - rc->code -= bound; - *prob -= *prob >> RC_MOVE_BITS; - bit = 1; - } - - return bit; -} - -/* Decode a bittree starting from the most significant bit. */ -static __always_inline uint32_t rc_bittree(struct rc_dec *rc, - uint16_t *probs, uint32_t limit) -{ - uint32_t symbol = 1; - - do { - if (rc_bit(rc, &probs[symbol])) - symbol = (symbol << 1) + 1; - else - symbol <<= 1; - } while (symbol < limit); - - return symbol; -} - -/* Decode a bittree starting from the least significant bit. */ -static __always_inline void rc_bittree_reverse(struct rc_dec *rc, - uint16_t *probs, - uint32_t *dest, uint32_t limit) -{ - uint32_t symbol = 1; - uint32_t i = 0; - - do { - if (rc_bit(rc, &probs[symbol])) { - symbol = (symbol << 1) + 1; - *dest += 1 << i; - } else { - symbol <<= 1; - } - } while (++i < limit); -} - -/* Decode direct bits (fixed fifty-fifty probability) */ -static inline void rc_direct(struct rc_dec *rc, uint32_t *dest, uint32_t limit) -{ - uint32_t mask; - - do { - rc_normalize(rc); - rc->range >>= 1; - rc->code -= rc->range; - mask = (uint32_t)0 - (rc->code >> 31); - rc->code += rc->range & mask; - *dest = (*dest << 1) + (mask + 1); - } while (--limit > 0); -} - -/******** - * LZMA * - ********/ - -/* Get pointer to literal coder probability array. */ -static uint16_t *lzma_literal_probs(struct xz_dec_lzma2 *s) -{ - uint32_t prev_byte = dict_get(&s->dict, 0); - uint32_t low = prev_byte >> (8 - s->lzma.lc); - uint32_t high = (s->dict.pos & s->lzma.literal_pos_mask) << s->lzma.lc; - return s->lzma.literal[low + high]; -} - -/* Decode a literal (one 8-bit byte) */ -static void lzma_literal(struct xz_dec_lzma2 *s) -{ - uint16_t *probs; - uint32_t symbol; - uint32_t match_byte; - uint32_t match_bit; - uint32_t offset; - uint32_t i; - - probs = lzma_literal_probs(s); - - if (lzma_state_is_literal(s->lzma.state)) { - symbol = rc_bittree(&s->rc, probs, 0x100); - } else { - symbol = 1; - match_byte = dict_get(&s->dict, s->lzma.rep0) << 1; - offset = 0x100; - - do { - match_bit = match_byte & offset; - match_byte <<= 1; - i = offset + match_bit + symbol; - - if (rc_bit(&s->rc, &probs[i])) { - symbol = (symbol << 1) + 1; - offset &= match_bit; - } else { - symbol <<= 1; - offset &= ~match_bit; - } - } while (symbol < 0x100); - } - - dict_put(&s->dict, (uint8_t)symbol); - lzma_state_literal(&s->lzma.state); -} - -/* Decode the length of the match into s->lzma.len. */ -static void lzma_len(struct xz_dec_lzma2 *s, struct lzma_len_dec *l, - uint32_t pos_state) -{ - uint16_t *probs; - uint32_t limit; - - if (!rc_bit(&s->rc, &l->choice)) { - probs = l->low[pos_state]; - limit = LEN_LOW_SYMBOLS; - s->lzma.len = MATCH_LEN_MIN; - } else { - if (!rc_bit(&s->rc, &l->choice2)) { - probs = l->mid[pos_state]; - limit = LEN_MID_SYMBOLS; - s->lzma.len = MATCH_LEN_MIN + LEN_LOW_SYMBOLS; - } else { - probs = l->high; - limit = LEN_HIGH_SYMBOLS; - s->lzma.len = MATCH_LEN_MIN + LEN_LOW_SYMBOLS - + LEN_MID_SYMBOLS; - } - } - - s->lzma.len += rc_bittree(&s->rc, probs, limit) - limit; -} - -/* Decode a match. The distance will be stored in s->lzma.rep0. */ -static void lzma_match(struct xz_dec_lzma2 *s, uint32_t pos_state) -{ - uint16_t *probs; - uint32_t dist_slot; - uint32_t limit; - - lzma_state_match(&s->lzma.state); - - s->lzma.rep3 = s->lzma.rep2; - s->lzma.rep2 = s->lzma.rep1; - s->lzma.rep1 = s->lzma.rep0; - - lzma_len(s, &s->lzma.match_len_dec, pos_state); - - probs = s->lzma.dist_slot[lzma_get_dist_state(s->lzma.len)]; - dist_slot = rc_bittree(&s->rc, probs, DIST_SLOTS) - DIST_SLOTS; - - if (dist_slot < DIST_MODEL_START) { - s->lzma.rep0 = dist_slot; - } else { - limit = (dist_slot >> 1) - 1; - s->lzma.rep0 = 2 + (dist_slot & 1); - - if (dist_slot < DIST_MODEL_END) { - s->lzma.rep0 <<= limit; - probs = s->lzma.dist_special + s->lzma.rep0 - - dist_slot - 1; - rc_bittree_reverse(&s->rc, probs, - &s->lzma.rep0, limit); - } else { - rc_direct(&s->rc, &s->lzma.rep0, limit - ALIGN_BITS); - s->lzma.rep0 <<= ALIGN_BITS; - rc_bittree_reverse(&s->rc, s->lzma.dist_align, - &s->lzma.rep0, ALIGN_BITS); - } - } -} - -/* - * Decode a repeated match. The distance is one of the four most recently - * seen matches. The distance will be stored in s->lzma.rep0. - */ -static void lzma_rep_match(struct xz_dec_lzma2 *s, uint32_t pos_state) -{ - uint32_t tmp; - - if (!rc_bit(&s->rc, &s->lzma.is_rep0[s->lzma.state])) { - if (!rc_bit(&s->rc, &s->lzma.is_rep0_long[ - s->lzma.state][pos_state])) { - lzma_state_short_rep(&s->lzma.state); - s->lzma.len = 1; - return; - } - } else { - if (!rc_bit(&s->rc, &s->lzma.is_rep1[s->lzma.state])) { - tmp = s->lzma.rep1; - } else { - if (!rc_bit(&s->rc, &s->lzma.is_rep2[s->lzma.state])) { - tmp = s->lzma.rep2; - } else { - tmp = s->lzma.rep3; - s->lzma.rep3 = s->lzma.rep2; - } - - s->lzma.rep2 = s->lzma.rep1; - } - - s->lzma.rep1 = s->lzma.rep0; - s->lzma.rep0 = tmp; - } - - lzma_state_long_rep(&s->lzma.state); - lzma_len(s, &s->lzma.rep_len_dec, pos_state); -} - -/* LZMA decoder core */ -static bool lzma_main(struct xz_dec_lzma2 *s) -{ - uint32_t pos_state; - - /* - * If the dictionary was reached during the previous call, try to - * finish the possibly pending repeat in the dictionary. - */ - if (dict_has_space(&s->dict) && s->lzma.len > 0) - dict_repeat(&s->dict, &s->lzma.len, s->lzma.rep0); - - /* - * Decode more LZMA symbols. One iteration may consume up to - * LZMA_IN_REQUIRED - 1 bytes. - */ - while (dict_has_space(&s->dict) && !rc_limit_exceeded(&s->rc)) { - pos_state = s->dict.pos & s->lzma.pos_mask; - - if (!rc_bit(&s->rc, &s->lzma.is_match[ - s->lzma.state][pos_state])) { - lzma_literal(s); - } else { - if (rc_bit(&s->rc, &s->lzma.is_rep[s->lzma.state])) - lzma_rep_match(s, pos_state); - else - lzma_match(s, pos_state); - - if (!dict_repeat(&s->dict, &s->lzma.len, s->lzma.rep0)) - return false; - } - } - - /* - * Having the range decoder always normalized when we are outside - * this function makes it easier to correctly handle end of the chunk. - */ - rc_normalize(&s->rc); - - return true; -} - -/* - * Reset the LZMA decoder and range decoder state. Dictionary is not reset - * here, because LZMA state may be reset without resetting the dictionary. - */ -static void lzma_reset(struct xz_dec_lzma2 *s) -{ - uint16_t *probs; - size_t i; - - s->lzma.state = STATE_LIT_LIT; - s->lzma.rep0 = 0; - s->lzma.rep1 = 0; - s->lzma.rep2 = 0; - s->lzma.rep3 = 0; - s->lzma.len = 0; - - /* - * All probabilities are initialized to the same value. This hack - * makes the code smaller by avoiding a separate loop for each - * probability array. - * - * This could be optimized so that only that part of literal - * probabilities that are actually required. In the common case - * we would write 12 KiB less. - */ - probs = s->lzma.is_match[0]; - for (i = 0; i < PROBS_TOTAL; ++i) - probs[i] = RC_BIT_MODEL_TOTAL / 2; - - rc_reset(&s->rc); -} - -/* - * Decode and validate LZMA properties (lc/lp/pb) and calculate the bit masks - * from the decoded lp and pb values. On success, the LZMA decoder state is - * reset and true is returned. - */ -static bool lzma_props(struct xz_dec_lzma2 *s, uint8_t props) -{ - if (props > (4 * 5 + 4) * 9 + 8) - return false; - - s->lzma.pos_mask = 0; - while (props >= 9 * 5) { - props -= 9 * 5; - ++s->lzma.pos_mask; - } - - s->lzma.pos_mask = (1 << s->lzma.pos_mask) - 1; - - s->lzma.literal_pos_mask = 0; - while (props >= 9) { - props -= 9; - ++s->lzma.literal_pos_mask; - } - - s->lzma.lc = props; - - if (s->lzma.lc + s->lzma.literal_pos_mask > 4) - return false; - - s->lzma.literal_pos_mask = (1 << s->lzma.literal_pos_mask) - 1; - - lzma_reset(s); - - return true; -} - -/********* - * LZMA2 * - *********/ - -/* - * The LZMA decoder assumes that if the input limit (s->rc.in_limit) hasn't - * been exceeded, it is safe to read up to LZMA_IN_REQUIRED bytes. This - * wrapper function takes care of making the LZMA decoder's assumption safe. - * - * As long as there is plenty of input left to be decoded in the current LZMA - * chunk, we decode directly from the caller-supplied input buffer until - * there's LZMA_IN_REQUIRED bytes left. Those remaining bytes are copied into - * s->temp.buf, which (hopefully) gets filled on the next call to this - * function. We decode a few bytes from the temporary buffer so that we can - * continue decoding from the caller-supplied input buffer again. - */ -static bool lzma2_lzma(struct xz_dec_lzma2 *s, struct xz_buf *b) -{ - size_t in_avail; - uint32_t tmp; - - in_avail = b->in_size - b->in_pos; - if (s->temp.size > 0 || s->lzma2.compressed == 0) { - tmp = 2 * LZMA_IN_REQUIRED - s->temp.size; - if (tmp > s->lzma2.compressed - s->temp.size) - tmp = s->lzma2.compressed - s->temp.size; - if (tmp > in_avail) - tmp = in_avail; - - memcpy(s->temp.buf + s->temp.size, b->in + b->in_pos, tmp); - - if (s->temp.size + tmp == s->lzma2.compressed) { - memzero(s->temp.buf + s->temp.size + tmp, - sizeof(s->temp.buf) - - s->temp.size - tmp); - s->rc.in_limit = s->temp.size + tmp; - } else if (s->temp.size + tmp < LZMA_IN_REQUIRED) { - s->temp.size += tmp; - b->in_pos += tmp; - return true; - } else { - s->rc.in_limit = s->temp.size + tmp - LZMA_IN_REQUIRED; - } - - s->rc.in = s->temp.buf; - s->rc.in_pos = 0; - - if (!lzma_main(s) || s->rc.in_pos > s->temp.size + tmp) - return false; - - s->lzma2.compressed -= s->rc.in_pos; - - if (s->rc.in_pos < s->temp.size) { - s->temp.size -= s->rc.in_pos; - memmove(s->temp.buf, s->temp.buf + s->rc.in_pos, - s->temp.size); - return true; - } - - b->in_pos += s->rc.in_pos - s->temp.size; - s->temp.size = 0; - } - - in_avail = b->in_size - b->in_pos; - if (in_avail >= LZMA_IN_REQUIRED) { - s->rc.in = b->in; - s->rc.in_pos = b->in_pos; - - if (in_avail >= s->lzma2.compressed + LZMA_IN_REQUIRED) - s->rc.in_limit = b->in_pos + s->lzma2.compressed; - else - s->rc.in_limit = b->in_size - LZMA_IN_REQUIRED; - - if (!lzma_main(s)) - return false; - - in_avail = s->rc.in_pos - b->in_pos; - if (in_avail > s->lzma2.compressed) - return false; - - s->lzma2.compressed -= in_avail; - b->in_pos = s->rc.in_pos; - } - - in_avail = b->in_size - b->in_pos; - if (in_avail < LZMA_IN_REQUIRED) { - if (in_avail > s->lzma2.compressed) - in_avail = s->lzma2.compressed; - - memcpy(s->temp.buf, b->in + b->in_pos, in_avail); - s->temp.size = in_avail; - b->in_pos += in_avail; - } - - return true; -} - -/* - * Take care of the LZMA2 control layer, and forward the job of actual LZMA - * decoding or copying of uncompressed chunks to other functions. - */ -XZ_EXTERN enum xz_ret xz_dec_lzma2_run(struct xz_dec_lzma2 *s, - struct xz_buf *b) -{ - uint32_t tmp; - - while (b->in_pos < b->in_size || s->lzma2.sequence == SEQ_LZMA_RUN) { - switch (s->lzma2.sequence) { - case SEQ_CONTROL: - /* - * LZMA2 control byte - * - * Exact values: - * 0x00 End marker - * 0x01 Dictionary reset followed by - * an uncompressed chunk - * 0x02 Uncompressed chunk (no dictionary reset) - * - * Highest three bits (s->control & 0xE0): - * 0xE0 Dictionary reset, new properties and state - * reset, followed by LZMA compressed chunk - * 0xC0 New properties and state reset, followed - * by LZMA compressed chunk (no dictionary - * reset) - * 0xA0 State reset using old properties, - * followed by LZMA compressed chunk (no - * dictionary reset) - * 0x80 LZMA chunk (no dictionary or state reset) - * - * For LZMA compressed chunks, the lowest five bits - * (s->control & 1F) are the highest bits of the - * uncompressed size (bits 16-20). - * - * A new LZMA2 stream must begin with a dictionary - * reset. The first LZMA chunk must set new - * properties and reset the LZMA state. - * - * Values that don't match anything described above - * are invalid and we return XZ_DATA_ERROR. - */ - tmp = b->in[b->in_pos++]; - - if (tmp == 0x00) - return XZ_STREAM_END; - - if (tmp >= 0xE0 || tmp == 0x01) { - s->lzma2.need_props = true; - s->lzma2.need_dict_reset = false; - dict_reset(&s->dict, b); - } else if (s->lzma2.need_dict_reset) { - return XZ_DATA_ERROR; - } - - if (tmp >= 0x80) { - s->lzma2.uncompressed = (tmp & 0x1F) << 16; - s->lzma2.sequence = SEQ_UNCOMPRESSED_1; - - if (tmp >= 0xC0) { - /* - * When there are new properties, - * state reset is done at - * SEQ_PROPERTIES. - */ - s->lzma2.need_props = false; - s->lzma2.next_sequence - = SEQ_PROPERTIES; - - } else if (s->lzma2.need_props) { - return XZ_DATA_ERROR; - - } else { - s->lzma2.next_sequence - = SEQ_LZMA_PREPARE; - if (tmp >= 0xA0) - lzma_reset(s); - } - } else { - if (tmp > 0x02) - return XZ_DATA_ERROR; - - s->lzma2.sequence = SEQ_COMPRESSED_0; - s->lzma2.next_sequence = SEQ_COPY; - } - - break; - - case SEQ_UNCOMPRESSED_1: - s->lzma2.uncompressed - += (uint32_t)b->in[b->in_pos++] << 8; - s->lzma2.sequence = SEQ_UNCOMPRESSED_2; - break; - - case SEQ_UNCOMPRESSED_2: - s->lzma2.uncompressed - += (uint32_t)b->in[b->in_pos++] + 1; - s->lzma2.sequence = SEQ_COMPRESSED_0; - break; - - case SEQ_COMPRESSED_0: - s->lzma2.compressed - = (uint32_t)b->in[b->in_pos++] << 8; - s->lzma2.sequence = SEQ_COMPRESSED_1; - break; - - case SEQ_COMPRESSED_1: - s->lzma2.compressed - += (uint32_t)b->in[b->in_pos++] + 1; - s->lzma2.sequence = s->lzma2.next_sequence; - break; - - case SEQ_PROPERTIES: - if (!lzma_props(s, b->in[b->in_pos++])) - return XZ_DATA_ERROR; - - s->lzma2.sequence = SEQ_LZMA_PREPARE; - - fallthrough; - - case SEQ_LZMA_PREPARE: - if (s->lzma2.compressed < RC_INIT_BYTES) - return XZ_DATA_ERROR; - - if (!rc_read_init(&s->rc, b)) - return XZ_OK; - - s->lzma2.compressed -= RC_INIT_BYTES; - s->lzma2.sequence = SEQ_LZMA_RUN; - - fallthrough; - - case SEQ_LZMA_RUN: - /* - * Set dictionary limit to indicate how much we want - * to be encoded at maximum. Decode new data into the - * dictionary. Flush the new data from dictionary to - * b->out. Check if we finished decoding this chunk. - * In case the dictionary got full but we didn't fill - * the output buffer yet, we may run this loop - * multiple times without changing s->lzma2.sequence. - */ - dict_limit(&s->dict, min_t(size_t, - b->out_size - b->out_pos, - s->lzma2.uncompressed)); - if (!lzma2_lzma(s, b)) - return XZ_DATA_ERROR; - - s->lzma2.uncompressed -= dict_flush(&s->dict, b); - - if (s->lzma2.uncompressed == 0) { - if (s->lzma2.compressed > 0 || s->lzma.len > 0 - || !rc_is_finished(&s->rc)) - return XZ_DATA_ERROR; - - rc_reset(&s->rc); - s->lzma2.sequence = SEQ_CONTROL; - - } else if (b->out_pos == b->out_size - || (b->in_pos == b->in_size - && s->temp.size - < s->lzma2.compressed)) { - return XZ_OK; - } - - break; - - case SEQ_COPY: - dict_uncompressed(&s->dict, b, &s->lzma2.compressed); - if (s->lzma2.compressed > 0) - return XZ_OK; - - s->lzma2.sequence = SEQ_CONTROL; - break; - } - } - - return XZ_OK; -} - -XZ_EXTERN struct xz_dec_lzma2 *xz_dec_lzma2_create(enum xz_mode mode, - uint32_t dict_max) -{ - struct xz_dec_lzma2 *s = kmalloc(sizeof(*s), GFP_KERNEL); - if (s == NULL) - return NULL; - - s->dict.mode = mode; - s->dict.size_max = dict_max; - - if (DEC_IS_PREALLOC(mode)) { - s->dict.buf = vmalloc(dict_max); - if (s->dict.buf == NULL) { - kfree(s); - return NULL; - } - } else if (DEC_IS_DYNALLOC(mode)) { - s->dict.buf = NULL; - s->dict.allocated = 0; - } - - return s; -} - -XZ_EXTERN enum xz_ret xz_dec_lzma2_reset(struct xz_dec_lzma2 *s, uint8_t props) -{ - /* This limits dictionary size to 3 GiB to keep parsing simpler. */ - if (props > 39) - return XZ_OPTIONS_ERROR; - - s->dict.size = 2 + (props & 1); - s->dict.size <<= (props >> 1) + 11; - - if (DEC_IS_MULTI(s->dict.mode)) { - if (s->dict.size > s->dict.size_max) - return XZ_MEMLIMIT_ERROR; - - s->dict.end = s->dict.size; - - if (DEC_IS_DYNALLOC(s->dict.mode)) { - if (s->dict.allocated < s->dict.size) { - s->dict.allocated = s->dict.size; - vfree(s->dict.buf); - s->dict.buf = vmalloc(s->dict.size); - if (s->dict.buf == NULL) { - s->dict.allocated = 0; - return XZ_MEM_ERROR; - } - } - } - } - - s->lzma2.sequence = SEQ_CONTROL; - s->lzma2.need_dict_reset = true; - - s->temp.size = 0; - - return XZ_OK; -} - -XZ_EXTERN void xz_dec_lzma2_end(struct xz_dec_lzma2 *s) -{ - if (DEC_IS_MULTI(s->dict.mode)) - vfree(s->dict.buf); - - kfree(s); -} - -#ifdef XZ_DEC_MICROLZMA -/* This is a wrapper struct to have a nice struct name in the public API. */ -struct xz_dec_microlzma { - struct xz_dec_lzma2 s; -}; - -XZ_EXTERN enum xz_ret xz_dec_microlzma_run(struct xz_dec_microlzma *s_ptr, - struct xz_buf *b) -{ - struct xz_dec_lzma2 *s = &s_ptr->s; - - /* - * sequence is SEQ_PROPERTIES before the first input byte, - * SEQ_LZMA_PREPARE until a total of five bytes have been read, - * and SEQ_LZMA_RUN for the rest of the input stream. - */ - if (s->lzma2.sequence != SEQ_LZMA_RUN) { - if (s->lzma2.sequence == SEQ_PROPERTIES) { - /* One byte is needed for the props. */ - if (b->in_pos >= b->in_size) - return XZ_OK; - - /* - * Don't increment b->in_pos here. The same byte is - * also passed to rc_read_init() which will ignore it. - */ - if (!lzma_props(s, ~b->in[b->in_pos])) - return XZ_DATA_ERROR; - - s->lzma2.sequence = SEQ_LZMA_PREPARE; - } - - /* - * xz_dec_microlzma_reset() doesn't validate the compressed - * size so we do it here. We have to limit the maximum size - * to avoid integer overflows in lzma2_lzma(). 3 GiB is a nice - * round number and much more than users of this code should - * ever need. - */ - if (s->lzma2.compressed < RC_INIT_BYTES - || s->lzma2.compressed > (3U << 30)) - return XZ_DATA_ERROR; - - if (!rc_read_init(&s->rc, b)) - return XZ_OK; - - s->lzma2.compressed -= RC_INIT_BYTES; - s->lzma2.sequence = SEQ_LZMA_RUN; - - dict_reset(&s->dict, b); - } - - /* This is to allow increasing b->out_size between calls. */ - if (DEC_IS_SINGLE(s->dict.mode)) - s->dict.end = b->out_size - b->out_pos; - - while (true) { - dict_limit(&s->dict, min_t(size_t, b->out_size - b->out_pos, - s->lzma2.uncompressed)); - - if (!lzma2_lzma(s, b)) - return XZ_DATA_ERROR; - - s->lzma2.uncompressed -= dict_flush(&s->dict, b); - - if (s->lzma2.uncompressed == 0) { - if (s->lzma2.pedantic_microlzma) { - if (s->lzma2.compressed > 0 || s->lzma.len > 0 - || !rc_is_finished(&s->rc)) - return XZ_DATA_ERROR; - } - - return XZ_STREAM_END; - } - - if (b->out_pos == b->out_size) - return XZ_OK; - - if (b->in_pos == b->in_size - && s->temp.size < s->lzma2.compressed) - return XZ_OK; - } -} - -XZ_EXTERN struct xz_dec_microlzma *xz_dec_microlzma_alloc(enum xz_mode mode, - uint32_t dict_size) -{ - struct xz_dec_microlzma *s; - - /* Restrict dict_size to the same range as in the LZMA2 code. */ - if (dict_size < 4096 || dict_size > (3U << 30)) - return NULL; - - s = kmalloc(sizeof(*s), GFP_KERNEL); - if (s == NULL) - return NULL; - - s->s.dict.mode = mode; - s->s.dict.size = dict_size; - - if (DEC_IS_MULTI(mode)) { - s->s.dict.end = dict_size; - - s->s.dict.buf = vmalloc(dict_size); - if (s->s.dict.buf == NULL) { - kfree(s); - return NULL; - } - } - - return s; -} - -XZ_EXTERN void xz_dec_microlzma_reset(struct xz_dec_microlzma *s, - uint32_t comp_size, - uint32_t uncomp_size, - int uncomp_size_is_exact) -{ - /* - * comp_size is validated in xz_dec_microlzma_run(). - * uncomp_size can safely be anything. - */ - s->s.lzma2.compressed = comp_size; - s->s.lzma2.uncompressed = uncomp_size; - s->s.lzma2.pedantic_microlzma = uncomp_size_is_exact; - - s->s.lzma2.sequence = SEQ_PROPERTIES; - s->s.temp.size = 0; -} - -XZ_EXTERN void xz_dec_microlzma_end(struct xz_dec_microlzma *s) -{ - if (DEC_IS_MULTI(s->s.dict.mode)) - vfree(s->s.dict.buf); - - kfree(s); -} -#endif diff --git a/android/app/src/main/cpp/xz/xz_dec_stream.c b/android/app/src/main/cpp/xz/xz_dec_stream.c deleted file mode 100644 index 33927e8e8..000000000 --- a/android/app/src/main/cpp/xz/xz_dec_stream.c +++ /dev/null @@ -1,984 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * .xz Stream decoder - * - * Author: Lasse Collin - */ - -#include "xz_private.h" -#include "xz_stream.h" - -#ifdef XZ_USE_CRC64 -# define IS_CRC64(check_type) ((check_type) == XZ_CHECK_CRC64) -#else -# define IS_CRC64(check_type) false -#endif - -#ifdef XZ_USE_SHA256 -# define IS_SHA256(check_type) ((check_type) == XZ_CHECK_SHA256) -#else -# define IS_SHA256(check_type) false -#endif - -/* Hash used to validate the Index field */ -struct xz_dec_hash { - vli_type unpadded; - vli_type uncompressed; - uint32_t crc32; -}; - -struct xz_dec { - /* Position in dec_main() */ - enum { - SEQ_STREAM_HEADER, - SEQ_BLOCK_START, - SEQ_BLOCK_HEADER, - SEQ_BLOCK_UNCOMPRESS, - SEQ_BLOCK_PADDING, - SEQ_BLOCK_CHECK, - SEQ_INDEX, - SEQ_INDEX_PADDING, - SEQ_INDEX_CRC32, - SEQ_STREAM_FOOTER, - SEQ_STREAM_PADDING - } sequence; - - /* Position in variable-length integers and Check fields */ - uint32_t pos; - - /* Variable-length integer decoded by dec_vli() */ - vli_type vli; - - /* Saved in_pos and out_pos */ - size_t in_start; - size_t out_start; - -#ifdef XZ_USE_CRC64 - /* CRC32 or CRC64 value in Block or CRC32 value in Index */ - uint64_t crc; -#else - /* CRC32 value in Block or Index */ - uint32_t crc; -#endif - - /* Type of the integrity check calculated from uncompressed data */ - enum xz_check check_type; - - /* Operation mode */ - enum xz_mode mode; - - /* - * True if the next call to xz_dec_run() is allowed to return - * XZ_BUF_ERROR. - */ - bool allow_buf_error; - - /* Information stored in Block Header */ - struct { - /* - * Value stored in the Compressed Size field, or - * VLI_UNKNOWN if Compressed Size is not present. - */ - vli_type compressed; - - /* - * Value stored in the Uncompressed Size field, or - * VLI_UNKNOWN if Uncompressed Size is not present. - */ - vli_type uncompressed; - - /* Size of the Block Header field */ - uint32_t size; - } block_header; - - /* Information collected when decoding Blocks */ - struct { - /* Observed compressed size of the current Block */ - vli_type compressed; - - /* Observed uncompressed size of the current Block */ - vli_type uncompressed; - - /* Number of Blocks decoded so far */ - vli_type count; - - /* - * Hash calculated from the Block sizes. This is used to - * validate the Index field. - */ - struct xz_dec_hash hash; - } block; - - /* Variables needed when verifying the Index field */ - struct { - /* Position in dec_index() */ - enum { - SEQ_INDEX_COUNT, - SEQ_INDEX_UNPADDED, - SEQ_INDEX_UNCOMPRESSED - } sequence; - - /* Size of the Index in bytes */ - vli_type size; - - /* Number of Records (matches block.count in valid files) */ - vli_type count; - - /* - * Hash calculated from the Records (matches block.hash in - * valid files). - */ - struct xz_dec_hash hash; - } index; - - /* - * Temporary buffer needed to hold Stream Header, Block Header, - * and Stream Footer. The Block Header is the biggest (1 KiB) - * so we reserve space according to that. buf[] has to be aligned - * to a multiple of four bytes; the size_t variables before it - * should guarantee this. - */ - struct { - size_t pos; - size_t size; - uint8_t buf[1024]; - } temp; - - struct xz_dec_lzma2 *lzma2; - -#ifdef XZ_DEC_BCJ - struct xz_dec_bcj *bcj; - bool bcj_active; -#endif - -#ifdef XZ_USE_SHA256 - /* - * SHA-256 value in Block - * - * struct xz_sha256 is over a hundred bytes and it's only accessed - * from a few places. By putting the SHA-256 state near the end - * of struct xz_dec (somewhere after the "index" member) reduces - * code size at least on x86 and RISC-V. It's because the first bytes - * of the struct can be accessed with smaller instructions; the - * members that are accessed from many places should be at the top. - */ - struct xz_sha256 sha256; -#endif -}; - -#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256) -/* Sizes of the Check field with different Check IDs */ -static const uint8_t check_sizes[16] = { - 0, - 4, 4, 4, - 8, 8, 8, - 16, 16, 16, - 32, 32, 32, - 64, 64, 64 -}; -#endif - -/* - * Fill s->temp by copying data starting from b->in[b->in_pos]. Caller - * must have set s->temp.pos and s->temp.size to indicate how much data - * we are supposed to copy into s->temp.buf. Return true once s->temp.pos - * has reached s->temp.size. - */ -static bool fill_temp(struct xz_dec *s, struct xz_buf *b) -{ - size_t copy_size = min_t(size_t, - b->in_size - b->in_pos, s->temp.size - s->temp.pos); - - memcpy(s->temp.buf + s->temp.pos, b->in + b->in_pos, copy_size); - b->in_pos += copy_size; - s->temp.pos += copy_size; - - if (s->temp.pos == s->temp.size) { - s->temp.pos = 0; - return true; - } - - return false; -} - -/* Decode a variable-length integer (little-endian base-128 encoding) */ -static enum xz_ret dec_vli(struct xz_dec *s, const uint8_t *in, - size_t *in_pos, size_t in_size) -{ - uint8_t byte; - - if (s->pos == 0) - s->vli = 0; - - while (*in_pos < in_size) { - byte = in[*in_pos]; - ++*in_pos; - - s->vli |= (vli_type)(byte & 0x7F) << s->pos; - - if ((byte & 0x80) == 0) { - /* Don't allow non-minimal encodings. */ - if (byte == 0 && s->pos != 0) - return XZ_DATA_ERROR; - - s->pos = 0; - return XZ_STREAM_END; - } - - s->pos += 7; - if (s->pos == 7 * VLI_BYTES_MAX) - return XZ_DATA_ERROR; - } - - return XZ_OK; -} - -/* - * Decode the Compressed Data field from a Block. Update and validate - * the observed compressed and uncompressed sizes of the Block so that - * they don't exceed the values possibly stored in the Block Header - * (validation assumes that no integer overflow occurs, since vli_type - * is normally uint64_t). Update the CRC32 or CRC64 value if presence of - * the CRC32 or CRC64 field was indicated in Stream Header. - * - * Once the decoding is finished, validate that the observed sizes match - * the sizes possibly stored in the Block Header. Update the hash and - * Block count, which are later used to validate the Index field. - */ -static enum xz_ret dec_block(struct xz_dec *s, struct xz_buf *b) -{ - enum xz_ret ret; - - s->in_start = b->in_pos; - s->out_start = b->out_pos; - -#ifdef XZ_DEC_BCJ - if (s->bcj_active) - ret = xz_dec_bcj_run(s->bcj, s->lzma2, b); - else -#endif - ret = xz_dec_lzma2_run(s->lzma2, b); - - s->block.compressed += b->in_pos - s->in_start; - s->block.uncompressed += b->out_pos - s->out_start; - - /* - * There is no need to separately check for VLI_UNKNOWN, since - * the observed sizes are always smaller than VLI_UNKNOWN. - */ - if (s->block.compressed > s->block_header.compressed - || s->block.uncompressed - > s->block_header.uncompressed) - return XZ_DATA_ERROR; - - if (s->check_type == XZ_CHECK_CRC32) - s->crc = xz_crc32(b->out + s->out_start, - b->out_pos - s->out_start, s->crc); -#ifdef XZ_USE_CRC64 - else if (s->check_type == XZ_CHECK_CRC64) - s->crc = xz_crc64(b->out + s->out_start, - b->out_pos - s->out_start, s->crc); -#endif -#ifdef XZ_USE_SHA256 - else if (s->check_type == XZ_CHECK_SHA256) - xz_sha256_update(b->out + s->out_start, - b->out_pos - s->out_start, &s->sha256); -#endif - - if (ret == XZ_STREAM_END) { - if (s->block_header.compressed != VLI_UNKNOWN - && s->block_header.compressed - != s->block.compressed) - return XZ_DATA_ERROR; - - if (s->block_header.uncompressed != VLI_UNKNOWN - && s->block_header.uncompressed - != s->block.uncompressed) - return XZ_DATA_ERROR; - - s->block.hash.unpadded += s->block_header.size - + s->block.compressed; - -#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256) - s->block.hash.unpadded += check_sizes[s->check_type]; -#else - if (s->check_type == XZ_CHECK_CRC32) - s->block.hash.unpadded += 4; - else if (IS_CRC64(s->check_type)) - s->block.hash.unpadded += 8; -#endif - - s->block.hash.uncompressed += s->block.uncompressed; - s->block.hash.crc32 = xz_crc32( - (const uint8_t *)&s->block.hash, - sizeof(s->block.hash), s->block.hash.crc32); - - ++s->block.count; - } - - return ret; -} - -/* Update the Index size and the CRC32 value. */ -static void index_update(struct xz_dec *s, const struct xz_buf *b) -{ - size_t in_used = b->in_pos - s->in_start; - s->index.size += in_used; - s->crc = xz_crc32(b->in + s->in_start, in_used, s->crc); -} - -/* - * Decode the Number of Records, Unpadded Size, and Uncompressed Size - * fields from the Index field. That is, Index Padding and CRC32 are not - * decoded by this function. - * - * This can return XZ_OK (more input needed), XZ_STREAM_END (everything - * successfully decoded), or XZ_DATA_ERROR (input is corrupt). - */ -static enum xz_ret dec_index(struct xz_dec *s, struct xz_buf *b) -{ - enum xz_ret ret; - - do { - ret = dec_vli(s, b->in, &b->in_pos, b->in_size); - if (ret != XZ_STREAM_END) { - index_update(s, b); - return ret; - } - - switch (s->index.sequence) { - case SEQ_INDEX_COUNT: - s->index.count = s->vli; - - /* - * Validate that the Number of Records field - * indicates the same number of Records as - * there were Blocks in the Stream. - */ - if (s->index.count != s->block.count) - return XZ_DATA_ERROR; - - s->index.sequence = SEQ_INDEX_UNPADDED; - break; - - case SEQ_INDEX_UNPADDED: - s->index.hash.unpadded += s->vli; - s->index.sequence = SEQ_INDEX_UNCOMPRESSED; - break; - - case SEQ_INDEX_UNCOMPRESSED: - s->index.hash.uncompressed += s->vli; - s->index.hash.crc32 = xz_crc32( - (const uint8_t *)&s->index.hash, - sizeof(s->index.hash), - s->index.hash.crc32); - --s->index.count; - s->index.sequence = SEQ_INDEX_UNPADDED; - break; - } - } while (s->index.count > 0); - - return XZ_STREAM_END; -} - -/* - * Validate that the next four or eight input bytes match the value - * of s->crc. s->pos must be zero when starting to validate the first byte. - * The "bits" argument allows using the same code for both CRC32 and CRC64. - */ -static enum xz_ret crc_validate(struct xz_dec *s, struct xz_buf *b, - uint32_t bits) -{ - do { - if (b->in_pos == b->in_size) - return XZ_OK; - - if (((s->crc >> s->pos) & 0xFF) != b->in[b->in_pos++]) - return XZ_DATA_ERROR; - - s->pos += 8; - - } while (s->pos < bits); - - s->crc = 0; - s->pos = 0; - - return XZ_STREAM_END; -} - -#ifdef XZ_DEC_ANY_CHECK -/* - * Skip over the Check field when the Check ID is not supported. - * Returns true once the whole Check field has been skipped over. - */ -static bool check_skip(struct xz_dec *s, struct xz_buf *b) -{ - while (s->pos < check_sizes[s->check_type]) { - if (b->in_pos == b->in_size) - return false; - - ++b->in_pos; - ++s->pos; - } - - s->pos = 0; - - return true; -} -#endif - -/* Decode the Stream Header field (the first 12 bytes of the .xz Stream). */ -static enum xz_ret dec_stream_header(struct xz_dec *s) -{ - if (!memeq(s->temp.buf, HEADER_MAGIC, HEADER_MAGIC_SIZE)) - return XZ_FORMAT_ERROR; - - if (xz_crc32(s->temp.buf + HEADER_MAGIC_SIZE, 2, 0) - != get_le32(s->temp.buf + HEADER_MAGIC_SIZE + 2)) - return XZ_DATA_ERROR; - - if (s->temp.buf[HEADER_MAGIC_SIZE] != 0) - return XZ_OPTIONS_ERROR; - - /* - * Of integrity checks, we support none (Check ID = 0), - * CRC32 (Check ID = 1), and optionally CRC64 (Check ID = 4). - * However, if XZ_DEC_ANY_CHECK is defined, we will accept other - * check types too, but then the check won't be verified and - * a warning (XZ_UNSUPPORTED_CHECK) will be given. - */ - if (s->temp.buf[HEADER_MAGIC_SIZE + 1] > XZ_CHECK_MAX) - return XZ_OPTIONS_ERROR; - - s->check_type = s->temp.buf[HEADER_MAGIC_SIZE + 1]; - - if (s->check_type > XZ_CHECK_CRC32 && !IS_CRC64(s->check_type) - && !IS_SHA256(s->check_type)) { -#ifdef XZ_DEC_ANY_CHECK - return XZ_UNSUPPORTED_CHECK; -#else - return XZ_OPTIONS_ERROR; -#endif - } - - return XZ_OK; -} - -/* Decode the Stream Footer field (the last 12 bytes of the .xz Stream) */ -static enum xz_ret dec_stream_footer(struct xz_dec *s) -{ - if (!memeq(s->temp.buf + 10, FOOTER_MAGIC, FOOTER_MAGIC_SIZE)) - return XZ_DATA_ERROR; - - if (xz_crc32(s->temp.buf + 4, 6, 0) != get_le32(s->temp.buf)) - return XZ_DATA_ERROR; - - /* - * Validate Backward Size. Note that we never added the size of the - * Index CRC32 field to s->index.size, thus we use s->index.size / 4 - * instead of s->index.size / 4 - 1. - */ - if ((s->index.size >> 2) != get_le32(s->temp.buf + 4)) - return XZ_DATA_ERROR; - - if (s->temp.buf[8] != 0 || s->temp.buf[9] != s->check_type) - return XZ_DATA_ERROR; - - /* - * Use XZ_STREAM_END instead of XZ_OK to be more convenient - * for the caller. - */ - return XZ_STREAM_END; -} - -/* Decode the Block Header and initialize the filter chain. */ -static enum xz_ret dec_block_header(struct xz_dec *s) -{ - enum xz_ret ret; - - /* - * Validate the CRC32. We know that the temp buffer is at least - * eight bytes so this is safe. - */ - s->temp.size -= 4; - if (xz_crc32(s->temp.buf, s->temp.size, 0) - != get_le32(s->temp.buf + s->temp.size)) - return XZ_DATA_ERROR; - - s->temp.pos = 2; - - /* - * Catch unsupported Block Flags. We support only one or two filters - * in the chain, so we catch that with the same test. - */ -#ifdef XZ_DEC_BCJ - if (s->temp.buf[1] & 0x3E) -#else - if (s->temp.buf[1] & 0x3F) -#endif - return XZ_OPTIONS_ERROR; - - /* Compressed Size */ - if (s->temp.buf[1] & 0x40) { - if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size) - != XZ_STREAM_END) - return XZ_DATA_ERROR; - - s->block_header.compressed = s->vli; - } else { - s->block_header.compressed = VLI_UNKNOWN; - } - - /* Uncompressed Size */ - if (s->temp.buf[1] & 0x80) { - if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size) - != XZ_STREAM_END) - return XZ_DATA_ERROR; - - s->block_header.uncompressed = s->vli; - } else { - s->block_header.uncompressed = VLI_UNKNOWN; - } - -#ifdef XZ_DEC_BCJ - /* If there are two filters, the first one must be a BCJ filter. */ - s->bcj_active = s->temp.buf[1] & 0x01; - if (s->bcj_active) { - if (s->temp.size - s->temp.pos < 2) - return XZ_OPTIONS_ERROR; - - ret = xz_dec_bcj_reset(s->bcj, s->temp.buf[s->temp.pos++]); - if (ret != XZ_OK) - return ret; - - /* - * We don't support custom start offset, - * so Size of Properties must be zero. - */ - if (s->temp.buf[s->temp.pos++] != 0x00) - return XZ_OPTIONS_ERROR; - } -#endif - - /* Valid Filter Flags always take at least two bytes. */ - if (s->temp.size - s->temp.pos < 2) - return XZ_DATA_ERROR; - - /* Filter ID = LZMA2 */ - if (s->temp.buf[s->temp.pos++] != 0x21) - return XZ_OPTIONS_ERROR; - - /* Size of Properties = 1-byte Filter Properties */ - if (s->temp.buf[s->temp.pos++] != 0x01) - return XZ_OPTIONS_ERROR; - - /* Filter Properties contains LZMA2 dictionary size. */ - if (s->temp.size - s->temp.pos < 1) - return XZ_DATA_ERROR; - - ret = xz_dec_lzma2_reset(s->lzma2, s->temp.buf[s->temp.pos++]); - if (ret != XZ_OK) - return ret; - - /* The rest must be Header Padding. */ - while (s->temp.pos < s->temp.size) - if (s->temp.buf[s->temp.pos++] != 0x00) - return XZ_OPTIONS_ERROR; - - s->temp.pos = 0; - s->block.compressed = 0; - s->block.uncompressed = 0; - - return XZ_OK; -} - -static enum xz_ret dec_main(struct xz_dec *s, struct xz_buf *b) -{ - enum xz_ret ret; - - /* - * Store the start position for the case when we are in the middle - * of the Index field. - */ - s->in_start = b->in_pos; - - while (true) { - switch (s->sequence) { - case SEQ_STREAM_HEADER: - /* - * Stream Header is copied to s->temp, and then - * decoded from there. This way if the caller - * gives us only little input at a time, we can - * still keep the Stream Header decoding code - * simple. Similar approach is used in many places - * in this file. - */ - if (!fill_temp(s, b)) - return XZ_OK; - - /* - * If dec_stream_header() returns - * XZ_UNSUPPORTED_CHECK, it is still possible - * to continue decoding if working in multi-call - * mode. Thus, update s->sequence before calling - * dec_stream_header(). - */ - s->sequence = SEQ_BLOCK_START; - - ret = dec_stream_header(s); - if (ret != XZ_OK) - return ret; - - fallthrough; - - case SEQ_BLOCK_START: - /* We need one byte of input to continue. */ - if (b->in_pos == b->in_size) - return XZ_OK; - - /* See if this is the beginning of the Index field. */ - if (b->in[b->in_pos] == 0) { - s->in_start = b->in_pos++; - s->sequence = SEQ_INDEX; - break; - } - - /* - * Calculate the size of the Block Header and - * prepare to decode it. - */ - s->block_header.size - = ((uint32_t)b->in[b->in_pos] + 1) * 4; - - s->temp.size = s->block_header.size; - s->temp.pos = 0; - s->sequence = SEQ_BLOCK_HEADER; - - fallthrough; - - case SEQ_BLOCK_HEADER: - if (!fill_temp(s, b)) - return XZ_OK; - - ret = dec_block_header(s); - if (ret != XZ_OK) - return ret; - -#ifdef XZ_USE_SHA256 - if (s->check_type == XZ_CHECK_SHA256) - xz_sha256_reset(&s->sha256); -#endif - - s->sequence = SEQ_BLOCK_UNCOMPRESS; - - fallthrough; - - case SEQ_BLOCK_UNCOMPRESS: - ret = dec_block(s, b); - if (ret != XZ_STREAM_END) - return ret; - - s->sequence = SEQ_BLOCK_PADDING; - - fallthrough; - - case SEQ_BLOCK_PADDING: - /* - * Size of Compressed Data + Block Padding - * must be a multiple of four. We don't need - * s->block.compressed for anything else - * anymore, so we use it here to test the size - * of the Block Padding field. - */ - while (s->block.compressed & 3) { - if (b->in_pos == b->in_size) - return XZ_OK; - - if (b->in[b->in_pos++] != 0) - return XZ_DATA_ERROR; - - ++s->block.compressed; - } - - s->sequence = SEQ_BLOCK_CHECK; - - fallthrough; - - case SEQ_BLOCK_CHECK: - if (s->check_type == XZ_CHECK_CRC32) { - ret = crc_validate(s, b, 32); - if (ret != XZ_STREAM_END) - return ret; - } - else if (IS_CRC64(s->check_type)) { - ret = crc_validate(s, b, 64); - if (ret != XZ_STREAM_END) - return ret; - } -#ifdef XZ_USE_SHA256 - else if (s->check_type == XZ_CHECK_SHA256) { - s->temp.size = 32; - if (!fill_temp(s, b)) - return XZ_OK; - - if (!xz_sha256_validate(s->temp.buf, - &s->sha256)) - return XZ_DATA_ERROR; - - s->pos = 0; - } -#endif -#ifdef XZ_DEC_ANY_CHECK - else if (!check_skip(s, b)) { - return XZ_OK; - } -#endif - - s->sequence = SEQ_BLOCK_START; - break; - - case SEQ_INDEX: - ret = dec_index(s, b); - if (ret != XZ_STREAM_END) - return ret; - - s->sequence = SEQ_INDEX_PADDING; - - fallthrough; - - case SEQ_INDEX_PADDING: - while ((s->index.size + (b->in_pos - s->in_start)) - & 3) { - if (b->in_pos == b->in_size) { - index_update(s, b); - return XZ_OK; - } - - if (b->in[b->in_pos++] != 0) - return XZ_DATA_ERROR; - } - - /* Finish the CRC32 value and Index size. */ - index_update(s, b); - - /* Compare the hashes to validate the Index field. */ - if (!memeq(&s->block.hash, &s->index.hash, - sizeof(s->block.hash))) - return XZ_DATA_ERROR; - - s->sequence = SEQ_INDEX_CRC32; - - fallthrough; - - case SEQ_INDEX_CRC32: - ret = crc_validate(s, b, 32); - if (ret != XZ_STREAM_END) - return ret; - - s->temp.size = STREAM_HEADER_SIZE; - s->sequence = SEQ_STREAM_FOOTER; - - fallthrough; - - case SEQ_STREAM_FOOTER: - if (!fill_temp(s, b)) - return XZ_OK; - - return dec_stream_footer(s); - - case SEQ_STREAM_PADDING: - /* Never reached, only silencing a warning */ - break; - } - } - - /* Never reached */ -} - -/* - * xz_dec_run() is a wrapper for dec_main() to handle some special cases in - * multi-call and single-call decoding. - * - * In multi-call mode, we must return XZ_BUF_ERROR when it seems clear that we - * are not going to make any progress anymore. This is to prevent the caller - * from calling us infinitely when the input file is truncated or otherwise - * corrupt. Since zlib-style API allows that the caller fills the input buffer - * only when the decoder doesn't produce any new output, we have to be careful - * to avoid returning XZ_BUF_ERROR too easily: XZ_BUF_ERROR is returned only - * after the second consecutive call to xz_dec_run() that makes no progress. - * - * In single-call mode, if we couldn't decode everything and no error - * occurred, either the input is truncated or the output buffer is too small. - * Since we know that the last input byte never produces any output, we know - * that if all the input was consumed and decoding wasn't finished, the file - * must be corrupt. Otherwise the output buffer has to be too small or the - * file is corrupt in a way that decoding it produces too big output. - * - * If single-call decoding fails, we reset b->in_pos and b->out_pos back to - * their original values. This is because with some filter chains there won't - * be any valid uncompressed data in the output buffer unless the decoding - * actually succeeds (that's the price to pay of using the output buffer as - * the workspace). - */ -XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b) -{ - size_t in_start; - size_t out_start; - enum xz_ret ret; - - if (DEC_IS_SINGLE(s->mode)) - xz_dec_reset(s); - - in_start = b->in_pos; - out_start = b->out_pos; - ret = dec_main(s, b); - - if (DEC_IS_SINGLE(s->mode)) { - if (ret == XZ_OK) - ret = b->in_pos == b->in_size - ? XZ_DATA_ERROR : XZ_BUF_ERROR; - - if (ret != XZ_STREAM_END) { - b->in_pos = in_start; - b->out_pos = out_start; - } - - } else if (ret == XZ_OK && in_start == b->in_pos - && out_start == b->out_pos) { - if (s->allow_buf_error) - ret = XZ_BUF_ERROR; - - s->allow_buf_error = true; - } else { - s->allow_buf_error = false; - } - - return ret; -} - -#ifdef XZ_DEC_CONCATENATED -XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b, - int finish) -{ - enum xz_ret ret; - - if (DEC_IS_SINGLE(s->mode)) { - xz_dec_reset(s); - finish = true; - } - - while (true) { - if (s->sequence == SEQ_STREAM_PADDING) { - /* - * Skip Stream Padding. Its size must be a multiple - * of four bytes which is tracked with s->pos. - */ - while (true) { - if (b->in_pos == b->in_size) { - /* - * Note that if we are repeatedly - * given no input and finish is false, - * we will keep returning XZ_OK even - * though no progress is being made. - * The lack of XZ_BUF_ERROR support - * isn't a problem here because a - * reasonable caller will eventually - * provide more input or set finish - * to true. - */ - if (!finish) - return XZ_OK; - - if (s->pos != 0) - return XZ_DATA_ERROR; - - return XZ_STREAM_END; - } - - if (b->in[b->in_pos] != 0x00) { - if (s->pos != 0) - return XZ_DATA_ERROR; - - break; - } - - ++b->in_pos; - s->pos = (s->pos + 1) & 3; - } - - /* - * More input remains. It should be a new Stream. - * - * In single-call mode xz_dec_run() will always call - * xz_dec_reset(). Thus, we need to do it here only - * in multi-call mode. - */ - if (DEC_IS_MULTI(s->mode)) - xz_dec_reset(s); - } - - ret = xz_dec_run(s, b); - - if (ret != XZ_STREAM_END) - break; - - s->sequence = SEQ_STREAM_PADDING; - } - - return ret; -} -#endif - -XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max) -{ - struct xz_dec *s = kmalloc(sizeof(*s), GFP_KERNEL); - if (s == NULL) - return NULL; - - s->mode = mode; - -#ifdef XZ_DEC_BCJ - s->bcj = xz_dec_bcj_create(DEC_IS_SINGLE(mode)); - if (s->bcj == NULL) - goto error_bcj; -#endif - - s->lzma2 = xz_dec_lzma2_create(mode, dict_max); - if (s->lzma2 == NULL) - goto error_lzma2; - - xz_dec_reset(s); - return s; - -error_lzma2: -#ifdef XZ_DEC_BCJ - xz_dec_bcj_end(s->bcj); -error_bcj: -#endif - kfree(s); - return NULL; -} - -XZ_EXTERN void xz_dec_reset(struct xz_dec *s) -{ - s->sequence = SEQ_STREAM_HEADER; - s->allow_buf_error = false; - s->pos = 0; - s->crc = 0; - memzero(&s->block, sizeof(s->block)); - memzero(&s->index, sizeof(s->index)); - s->temp.pos = 0; - s->temp.size = STREAM_HEADER_SIZE; -} - -XZ_EXTERN void xz_dec_end(struct xz_dec *s) -{ - if (s != NULL) { - xz_dec_lzma2_end(s->lzma2); -#ifdef XZ_DEC_BCJ - xz_dec_bcj_end(s->bcj); -#endif - kfree(s); - } -} diff --git a/android/app/src/main/cpp/xz/xz_lzma2.h b/android/app/src/main/cpp/xz/xz_lzma2.h deleted file mode 100644 index d2632b7df..000000000 --- a/android/app/src/main/cpp/xz/xz_lzma2.h +++ /dev/null @@ -1,203 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -/* - * LZMA2 definitions - * - * Authors: Lasse Collin - * Igor Pavlov - */ - -#ifndef XZ_LZMA2_H -#define XZ_LZMA2_H - -/* Range coder constants */ -#define RC_SHIFT_BITS 8 -#define RC_TOP_BITS 24 -#define RC_TOP_VALUE (1 << RC_TOP_BITS) -#define RC_BIT_MODEL_TOTAL_BITS 11 -#define RC_BIT_MODEL_TOTAL (1 << RC_BIT_MODEL_TOTAL_BITS) -#define RC_MOVE_BITS 5 - -/* - * Maximum number of position states. A position state is the lowest pb - * number of bits of the current uncompressed offset. In some places there - * are different sets of probabilities for different position states. - */ -#define POS_STATES_MAX (1 << 4) - -/* - * This enum is used to track which LZMA symbols have occurred most recently - * and in which order. This information is used to predict the next symbol. - * - * Symbols: - * - Literal: One 8-bit byte - * - Match: Repeat a chunk of data at some distance - * - Long repeat: Multi-byte match at a recently seen distance - * - Short repeat: One-byte repeat at a recently seen distance - * - * The symbol names are in from STATE_oldest_older_previous. REP means - * either short or long repeated match, and NONLIT means any non-literal. - */ -enum lzma_state { - STATE_LIT_LIT, - STATE_MATCH_LIT_LIT, - STATE_REP_LIT_LIT, - STATE_SHORTREP_LIT_LIT, - STATE_MATCH_LIT, - STATE_REP_LIT, - STATE_SHORTREP_LIT, - STATE_LIT_MATCH, - STATE_LIT_LONGREP, - STATE_LIT_SHORTREP, - STATE_NONLIT_MATCH, - STATE_NONLIT_REP -}; - -/* Total number of states */ -#define STATES 12 - -/* The lowest 7 states indicate that the previous state was a literal. */ -#define LIT_STATES 7 - -/* Indicate that the latest symbol was a literal. */ -static inline void lzma_state_literal(enum lzma_state *state) -{ - if (*state <= STATE_SHORTREP_LIT_LIT) - *state = STATE_LIT_LIT; - else if (*state <= STATE_LIT_SHORTREP) - *state -= 3; - else - *state -= 6; -} - -/* Indicate that the latest symbol was a match. */ -static inline void lzma_state_match(enum lzma_state *state) -{ - *state = *state < LIT_STATES ? STATE_LIT_MATCH : STATE_NONLIT_MATCH; -} - -/* Indicate that the latest state was a long repeated match. */ -static inline void lzma_state_long_rep(enum lzma_state *state) -{ - *state = *state < LIT_STATES ? STATE_LIT_LONGREP : STATE_NONLIT_REP; -} - -/* Indicate that the latest symbol was a short match. */ -static inline void lzma_state_short_rep(enum lzma_state *state) -{ - *state = *state < LIT_STATES ? STATE_LIT_SHORTREP : STATE_NONLIT_REP; -} - -/* Test if the previous symbol was a literal. */ -static inline bool lzma_state_is_literal(enum lzma_state state) -{ - return state < LIT_STATES; -} - -/* Each literal coder is divided in three sections: - * - 0x001-0x0FF: Without match byte - * - 0x101-0x1FF: With match byte; match bit is 0 - * - 0x201-0x2FF: With match byte; match bit is 1 - * - * Match byte is used when the previous LZMA symbol was something else than - * a literal (that is, it was some kind of match). - */ -#define LITERAL_CODER_SIZE 0x300 - -/* Maximum number of literal coders */ -#define LITERAL_CODERS_MAX (1 << 4) - -/* Minimum length of a match is two bytes. */ -#define MATCH_LEN_MIN 2 - -/* Match length is encoded with 4, 5, or 10 bits. - * - * Length Bits - * 2-9 4 = Choice=0 + 3 bits - * 10-17 5 = Choice=1 + Choice2=0 + 3 bits - * 18-273 10 = Choice=1 + Choice2=1 + 8 bits - */ -#define LEN_LOW_BITS 3 -#define LEN_LOW_SYMBOLS (1 << LEN_LOW_BITS) -#define LEN_MID_BITS 3 -#define LEN_MID_SYMBOLS (1 << LEN_MID_BITS) -#define LEN_HIGH_BITS 8 -#define LEN_HIGH_SYMBOLS (1 << LEN_HIGH_BITS) -#define LEN_SYMBOLS (LEN_LOW_SYMBOLS + LEN_MID_SYMBOLS + LEN_HIGH_SYMBOLS) - -/* - * Maximum length of a match is 273 which is a result of the encoding - * described above. - */ -#define MATCH_LEN_MAX (MATCH_LEN_MIN + LEN_SYMBOLS - 1) - -/* - * Different sets of probabilities are used for match distances that have - * very short match length: Lengths of 2, 3, and 4 bytes have a separate - * set of probabilities for each length. The matches with longer length - * use a shared set of probabilities. - */ -#define DIST_STATES 4 - -/* - * Get the index of the appropriate probability array for decoding - * the distance slot. - */ -static inline uint32_t lzma_get_dist_state(uint32_t len) -{ - return len < DIST_STATES + MATCH_LEN_MIN - ? len - MATCH_LEN_MIN : DIST_STATES - 1; -} - -/* - * The highest two bits of a 32-bit match distance are encoded using six bits. - * This six-bit value is called a distance slot. This way encoding a 32-bit - * value takes 6-36 bits, larger values taking more bits. - */ -#define DIST_SLOT_BITS 6 -#define DIST_SLOTS (1 << DIST_SLOT_BITS) - -/* Match distances up to 127 are fully encoded using probabilities. Since - * the highest two bits (distance slot) are always encoded using six bits, - * the distances 0-3 don't need any additional bits to encode, since the - * distance slot itself is the same as the actual distance. DIST_MODEL_START - * indicates the first distance slot where at least one additional bit is - * needed. - */ -#define DIST_MODEL_START 4 - -/* - * Match distances greater than 127 are encoded in three pieces: - * - distance slot: the highest two bits - * - direct bits: 2-26 bits below the highest two bits - * - alignment bits: four lowest bits - * - * Direct bits don't use any probabilities. - * - * The distance slot value of 14 is for distances 128-191. - */ -#define DIST_MODEL_END 14 - -/* Distance slots that indicate a distance <= 127. */ -#define FULL_DISTANCES_BITS (DIST_MODEL_END / 2) -#define FULL_DISTANCES (1 << FULL_DISTANCES_BITS) - -/* - * For match distances greater than 127, only the highest two bits and the - * lowest four bits (alignment) is encoded using probabilities. - */ -#define ALIGN_BITS 4 -#define ALIGN_SIZE (1 << ALIGN_BITS) -#define ALIGN_MASK (ALIGN_SIZE - 1) - -/* Total number of all probability variables */ -#define PROBS_TOTAL (1846 + LITERAL_CODERS_MAX * LITERAL_CODER_SIZE) - -/* - * LZMA remembers the four most recent match distances. Reusing these - * distances tends to take less space than re-encoding the actual - * distance value. - */ -#define REPS 4 - -#endif diff --git a/android/app/src/main/cpp/xz/xz_private.h b/android/app/src/main/cpp/xz/xz_private.h deleted file mode 100644 index 7387401da..000000000 --- a/android/app/src/main/cpp/xz/xz_private.h +++ /dev/null @@ -1,189 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -/* - * Private includes and definitions - * - * Author: Lasse Collin - */ - -#ifndef XZ_PRIVATE_H -#define XZ_PRIVATE_H - -#ifdef __KERNEL__ -# include -# include -# include - /* XZ_PREBOOT may be defined only via decompress_unxz.c. */ -# ifndef XZ_PREBOOT -# include -# include -# include -# ifdef CONFIG_XZ_DEC_X86 -# define XZ_DEC_X86 -# endif -# ifdef CONFIG_XZ_DEC_POWERPC -# define XZ_DEC_POWERPC -# endif -# ifdef CONFIG_XZ_DEC_IA64 -# define XZ_DEC_IA64 -# endif -# ifdef CONFIG_XZ_DEC_ARM -# define XZ_DEC_ARM -# endif -# ifdef CONFIG_XZ_DEC_ARMTHUMB -# define XZ_DEC_ARMTHUMB -# endif -# ifdef CONFIG_XZ_DEC_SPARC -# define XZ_DEC_SPARC -# endif -# ifdef CONFIG_XZ_DEC_ARM64 -# define XZ_DEC_ARM64 -# endif -# ifdef CONFIG_XZ_DEC_RISCV -# define XZ_DEC_RISCV -# endif -# ifdef CONFIG_XZ_DEC_MICROLZMA -# define XZ_DEC_MICROLZMA -# endif -# define memeq(a, b, size) (memcmp(a, b, size) == 0) -# define memzero(buf, size) memset(buf, 0, size) -# endif -# define get_le32(p) le32_to_cpup((const uint32_t *)(p)) -#else - /* - * For userspace builds, use a separate header to define the required - * macros and functions. This makes it easier to adapt the code into - * different environments and avoids clutter in the Linux kernel tree. - */ -# include "xz_config.h" -#endif - -/* If no specific decoding mode is requested, enable support for all modes. */ -#if !defined(XZ_DEC_SINGLE) && !defined(XZ_DEC_PREALLOC) \ - && !defined(XZ_DEC_DYNALLOC) -# define XZ_DEC_SINGLE -# define XZ_DEC_PREALLOC -# define XZ_DEC_DYNALLOC -#endif - -/* - * The DEC_IS_foo(mode) macros are used in "if" statements. If only some - * of the supported modes are enabled, these macros will evaluate to true or - * false at compile time and thus allow the compiler to omit unneeded code. - */ -#ifdef XZ_DEC_SINGLE -# define DEC_IS_SINGLE(mode) ((mode) == XZ_SINGLE) -#else -# define DEC_IS_SINGLE(mode) (false) -#endif - -#ifdef XZ_DEC_PREALLOC -# define DEC_IS_PREALLOC(mode) ((mode) == XZ_PREALLOC) -#else -# define DEC_IS_PREALLOC(mode) (false) -#endif - -#ifdef XZ_DEC_DYNALLOC -# define DEC_IS_DYNALLOC(mode) ((mode) == XZ_DYNALLOC) -#else -# define DEC_IS_DYNALLOC(mode) (false) -#endif - -#if !defined(XZ_DEC_SINGLE) -# define DEC_IS_MULTI(mode) (true) -#elif defined(XZ_DEC_PREALLOC) || defined(XZ_DEC_DYNALLOC) -# define DEC_IS_MULTI(mode) ((mode) != XZ_SINGLE) -#else -# define DEC_IS_MULTI(mode) (false) -#endif - -/* - * If any of the BCJ filter decoders are wanted, define XZ_DEC_BCJ. - * XZ_DEC_BCJ is used to enable generic support for BCJ decoders. - */ -#ifndef XZ_DEC_BCJ -# if defined(XZ_DEC_X86) || defined(XZ_DEC_POWERPC) \ - || defined(XZ_DEC_IA64) \ - || defined(XZ_DEC_ARM) || defined(XZ_DEC_ARMTHUMB) \ - || defined(XZ_DEC_SPARC) || defined(XZ_DEC_ARM64) \ - || defined(XZ_DEC_RISCV) -# define XZ_DEC_BCJ -# endif -#endif - -struct xz_sha256 { - /* Buffered input data */ - uint8_t data[64]; - - /* Internal state and the final hash value */ - uint32_t state[8]; - - /* Size of the input data */ - uint64_t size; -}; - -/* Reset the SHA-256 state to prepare for a new calculation. */ -XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s); - -/* Update the SHA-256 state with new data. */ -XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size, - struct xz_sha256 *s); - -/* - * Finish the SHA-256 calculation. Compare the result with the first 32 bytes - * from buf. Return true if the values are equal and false if they aren't. - */ -XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s); - -/* - * Allocate memory for LZMA2 decoder. xz_dec_lzma2_reset() must be used - * before calling xz_dec_lzma2_run(). - */ -XZ_EXTERN struct xz_dec_lzma2 *xz_dec_lzma2_create(enum xz_mode mode, - uint32_t dict_max); - -/* - * Decode the LZMA2 properties (one byte) and reset the decoder. Return - * XZ_OK on success, XZ_MEMLIMIT_ERROR if the preallocated dictionary is not - * big enough, and XZ_OPTIONS_ERROR if props indicates something that this - * decoder doesn't support. - */ -XZ_EXTERN enum xz_ret xz_dec_lzma2_reset(struct xz_dec_lzma2 *s, - uint8_t props); - -/* Decode raw LZMA2 stream from b->in to b->out. */ -XZ_EXTERN enum xz_ret xz_dec_lzma2_run(struct xz_dec_lzma2 *s, - struct xz_buf *b); - -/* Free the memory allocated for the LZMA2 decoder. */ -XZ_EXTERN void xz_dec_lzma2_end(struct xz_dec_lzma2 *s); - -#ifdef XZ_DEC_BCJ -/* - * Allocate memory for BCJ decoders. xz_dec_bcj_reset() must be used before - * calling xz_dec_bcj_run(). - */ -XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call); - -/* - * Decode the Filter ID of a BCJ filter. This implementation doesn't - * support custom start offsets, so no decoding of Filter Properties - * is needed. Returns XZ_OK if the given Filter ID is supported. - * Otherwise XZ_OPTIONS_ERROR is returned. - */ -XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id); - -/* - * Decode raw BCJ + LZMA2 stream. This must be used only if there actually is - * a BCJ filter in the chain. If the chain has only LZMA2, xz_dec_lzma2_run() - * must be called directly. - */ -XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s, - struct xz_dec_lzma2 *lzma2, - struct xz_buf *b); - -/* Free the memory allocated for the BCJ filters. */ -#define xz_dec_bcj_end(s) kfree(s) -#endif - -#endif diff --git a/android/app/src/main/cpp/xz/xz_sha256.c b/android/app/src/main/cpp/xz/xz_sha256.c deleted file mode 100644 index 078cad2c8..000000000 --- a/android/app/src/main/cpp/xz/xz_sha256.c +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/* - * SHA-256 - * - * This is based on the XZ Utils version which is based public domain code - * from Crypto++ Library 5.5.1 released in 2007: https://www.cryptopp.com/ - * - * Authors: Wei Dai - * Lasse Collin - */ - -#include "xz_private.h" - -static inline uint32_t -rotr_32(uint32_t num, unsigned amount) -{ - return (num >> amount) | (num << (32 - amount)); -} - -#define blk0(i) (W[i] = get_be32(&data[4 * i])) -#define blk2(i) (W[i & 15] += s1(W[(i - 2) & 15]) + W[(i - 7) & 15] \ - + s0(W[(i - 15) & 15])) - -#define Ch(x, y, z) (z ^ (x & (y ^ z))) -#define Maj(x, y, z) ((x & (y ^ z)) + (y & z)) - -#define a(i) T[(0 - i) & 7] -#define b(i) T[(1 - i) & 7] -#define c(i) T[(2 - i) & 7] -#define d(i) T[(3 - i) & 7] -#define e(i) T[(4 - i) & 7] -#define f(i) T[(5 - i) & 7] -#define g(i) T[(6 - i) & 7] -#define h(i) T[(7 - i) & 7] - -#define R(i, j, blk) \ - h(i) += S1(e(i)) + Ch(e(i), f(i), g(i)) + SHA256_K[i + j] + blk; \ - d(i) += h(i); \ - h(i) += S0(a(i)) + Maj(a(i), b(i), c(i)) -#define R0(i) R(i, 0, blk0(i)) -#define R2(i) R(i, j, blk2(i)) - -#define S0(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 9), 11), 2) -#define S1(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 14), 5), 6) -#define s0(x) (rotr_32(x ^ rotr_32(x, 11), 7) ^ (x >> 3)) -#define s1(x) (rotr_32(x ^ rotr_32(x, 2), 17) ^ (x >> 10)) - -static const uint32_t SHA256_K[64] = { - 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, - 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, - 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, - 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, - 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, - 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, - 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, - 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, - 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, - 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, - 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, - 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, - 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, - 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, - 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, - 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2 -}; - -static void -transform(uint32_t state[8], const uint8_t data[64]) -{ - uint32_t W[16]; - uint32_t T[8]; - unsigned int j; - - /* Copy state[] to working vars. */ - memcpy(T, state, sizeof(T)); - - /* The first 16 operations unrolled */ - R0( 0); R0( 1); R0( 2); R0( 3); - R0( 4); R0( 5); R0( 6); R0( 7); - R0( 8); R0( 9); R0(10); R0(11); - R0(12); R0(13); R0(14); R0(15); - - /* The remaining 48 operations partially unrolled */ - for (j = 16; j < 64; j += 16) { - R2( 0); R2( 1); R2( 2); R2( 3); - R2( 4); R2( 5); R2( 6); R2( 7); - R2( 8); R2( 9); R2(10); R2(11); - R2(12); R2(13); R2(14); R2(15); - } - - /* Add the working vars back into state[]. */ - state[0] += a(0); - state[1] += b(0); - state[2] += c(0); - state[3] += d(0); - state[4] += e(0); - state[5] += f(0); - state[6] += g(0); - state[7] += h(0); -} - -XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s) -{ - static const uint32_t initial_state[8] = { - 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, - 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19 - }; - - memcpy(s->state, initial_state, sizeof(initial_state)); - s->size = 0; -} - -XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size, - struct xz_sha256 *s) -{ - size_t copy_start; - size_t copy_size; - - /* - * Copy the input data into a properly aligned temporary buffer. - * This way we can be called with arbitrarily sized buffers - * (no need to be a multiple of 64 bytes). - * - * Full 64-byte chunks could be processed directly from buf with - * unaligned access. It seemed to make very little difference in - * speed on x86-64 though. Thus it was omitted. - */ - while (size > 0) { - copy_start = s->size & 0x3F; - copy_size = 64 - copy_start; - if (copy_size > size) - copy_size = size; - - memcpy(s->data + copy_start, buf, copy_size); - - buf += copy_size; - size -= copy_size; - s->size += copy_size; - - if ((s->size & 0x3F) == 0) - transform(s->state, s->data); - } -} - -XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s) -{ - /* - * Add padding as described in RFC 3174 (it describes SHA-1 but - * the same padding style is used for SHA-256 too). - */ - size_t i = s->size & 0x3F; - s->data[i++] = 0x80; - - while (i != 64 - 8) { - if (i == 64) { - transform(s->state, s->data); - i = 0; - } - - s->data[i++] = 0x00; - } - - /* Convert the message size from bytes to bits. */ - s->size *= 8; - - /* - * Store the message size in big endian byte order and - * calculate the final hash value. - */ - for (i = 0; i < 8; ++i) - s->data[64 - 8 + i] = (uint8_t)(s->size >> ((7 - i) * 8)); - - transform(s->state, s->data); - - /* Compare if the hash value matches the first 32 bytes in buf. */ - for (i = 0; i < 8; ++i) - if (get_unaligned_be32(buf + 4 * i) != s->state[i]) - return false; - - return true; -} diff --git a/android/app/src/main/cpp/xz/xz_stream.h b/android/app/src/main/cpp/xz/xz_stream.h deleted file mode 100644 index 55f9f6f94..000000000 --- a/android/app/src/main/cpp/xz/xz_stream.h +++ /dev/null @@ -1,61 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -/* - * Definitions for handling the .xz file format - * - * Author: Lasse Collin - */ - -#ifndef XZ_STREAM_H -#define XZ_STREAM_H - -#if defined(__KERNEL__) && !XZ_INTERNAL_CRC32 -# include -# undef crc32 -# define xz_crc32(buf, size, crc) \ - (~crc32_le(~(uint32_t)(crc), buf, size)) -#endif - -/* - * See the .xz file format specification at - * https://tukaani.org/xz/xz-file-format.txt - * to understand the container format. - */ - -#define STREAM_HEADER_SIZE 12 - -#define HEADER_MAGIC "\3757zXZ" -#define HEADER_MAGIC_SIZE 6 - -#define FOOTER_MAGIC "YZ" -#define FOOTER_MAGIC_SIZE 2 - -/* - * Variable-length integer can hold a 63-bit unsigned integer or a special - * value indicating that the value is unknown. - * - * Experimental: vli_type can be defined to uint32_t to save a few bytes - * in code size (no effect on speed). Doing so limits the uncompressed and - * compressed size of the file to less than 256 MiB and may also weaken - * error detection slightly. - */ -typedef uint64_t vli_type; - -#define VLI_MAX ((vli_type)-1 / 2) -#define VLI_UNKNOWN ((vli_type)-1) - -/* Maximum encoded size of a VLI */ -#define VLI_BYTES_MAX (sizeof(vli_type) * 8 / 7) - -/* Integrity Check types */ -enum xz_check { - XZ_CHECK_NONE = 0, - XZ_CHECK_CRC32 = 1, - XZ_CHECK_CRC64 = 4, - XZ_CHECK_SHA256 = 10 -}; - -/* Maximum possible Check ID */ -#define XZ_CHECK_MAX 15 - -#endif diff --git a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt index d6968038c..c8e5cb554 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -1,41 +1,11 @@ package me.kavishdevar.librepods import android.app.Application -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import io.github.libxposed.service.XposedService -import io.github.libxposed.service.XposedServiceHelper -import me.kavishdevar.librepods.billing.BillingManager -import me.kavishdevar.librepods.billing.BillingProviderFactory -import me.kavishdevar.librepods.utils.XposedServiceHolder -import me.kavishdevar.librepods.utils.XposedState -class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { - - override fun onCreate() { - XposedServiceHelper.registerListener(this) - BillingManager.provider = BillingProviderFactory.create(this) - ProcessLifecycleOwner.get().lifecycle.addObserver(this) - - super.onCreate() - - } - - override fun onResume(owner: LifecycleOwner) { - BillingManager.provider.queryPurchases() - XposedState.isAvailable = XposedServiceHolder.service != null - XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true - } - - override fun onServiceBind(service: XposedService) { - XposedServiceHolder.service = service - XposedState.isAvailable = true - XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true - } - - override fun onServiceDied(p0: XposedService) { - XposedServiceHolder.service = null - XposedState.isAvailable = false - } -} +/** + * Application entry point for the autonomous Wear OS build. + * + * Platform-specific initialization is intentionally kept minimal. Bluetooth + * and AirPods protocol lifecycle belongs to the dedicated service layer. + */ +class LibrePodsApplication : Application() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 2e7b49a98..0f0842867 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -1,245 +1,123 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - package me.kavishdevar.librepods -// import me.kavishdevar.librepods.screens.Onboarding -// import me.kavishdevar.librepods.utils.RadareOffsetFinder -//import dagger.hilt.android.AndroidEntryPoint -import android.annotation.SuppressLint -import android.app.Activity -import android.content.BroadcastReceiver +import android.Manifest import android.content.ComponentName import android.content.Context -import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences +import android.content.pm.PackageManager import android.os.Bundle import android.os.IBinder -import android.util.Log +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.edit -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.android.play.core.review.ReviewManagerFactory -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.ControlCommandRepository -import me.kavishdevar.librepods.presentation.navigation.NavigationRoot -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.utils.XposedState -import kotlin.io.encoding.ExperimentalEncodingApi - -lateinit var serviceConnection: ServiceConnection -lateinit var connectionStatusReceiver: BroadcastReceiver -lateinit var testReviewReceiver: BroadcastReceiver - -//@AndroidEntryPoint -@ExperimentalMaterial3Api +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import me.kavishdevar.librepods.wear.service.LibrePodsWearService +import me.kavishdevar.librepods.wear.ui.AirPodsHomeScreen +import me.kavishdevar.librepods.wear.ui.StartupScreen + +/** + * Wear OS entry point. + * + * The activity owns no protocol state: it binds to [LibrePodsWearService], + * which keeps the AirPods session alive independently of the UI. Pairing is + * delegated to the system Bluetooth UI. + */ class MainActivity : ComponentActivity() { - companion object { - init { - if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { - System.loadLibrary("l2c_fcr_hook") - } + private var service by mutableStateOf(null) + private var permissionDenied by mutableStateOf(false) + private var bound = false + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + service = (binder as? LibrePodsWearService.LocalBinder)?.service + refreshDevices() + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null } } - @ExperimentalHazeMaterialsApi + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + permissionDenied = requiredPermissions().any { result[it] == false } + if (!permissionDenied) startAndBindService() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() - - setContent { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) } - - val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - when (key) { - "m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true) - } - } - DisposableEffect(Unit) { - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - onDispose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) - } - } - LibrePodsTheme( - m3eEnabled = m3eEnabled.value - ) { -// For demo screenshots -// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) -// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE -// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) + if (hasAllPermissions()) startAndBindService() else permissionLauncher.launch(requiredPermissions()) - Main() + setContent { + val active = service + if (active == null) { + StartupScreen( + message = if (permissionDenied) "Bluetooth permission is required" else "Starting…", + onOpenSettings = ::openSystemBluetoothSettings, + ) + } else { + AirPodsHomeScreen( + controller = active.controller, + scanner = active.scanner, + onOpenSystemBluetooth = ::openSystemBluetoothSettings, + onRefresh = ::refreshDevices, + ) } } } - override fun onDestroy() { - try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } - try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unregistering receiver: $e") + private fun startAndBindService() { + LibrePodsWearService.start(this) + if (!bound) { + bound = bindService( + Intent(this, LibrePodsWearService::class.java), + serviceConnection, + Context.BIND_AUTO_CREATE, + ) } - sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS)) - super.onDestroy() } - override fun onStop() { - try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } - try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unregistering receiver: $e") + private fun requiredPermissions(): Array = buildList { + if (android.os.Build.VERSION.SDK_INT >= 31) { + add(Manifest.permission.BLUETOOTH_CONNECT) + add(Manifest.permission.BLUETOOTH_SCAN) } - super.onStop() - } -} - -@ExperimentalHazeMaterialsApi -@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) -@Composable -fun Main() { - val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) - - val airPodsService = remember { mutableStateOf(null) } - - val airPodsViewModel: AirPodsViewModel = viewModel() - - LaunchedEffect(Unit) { - if (BuildConfig.PLAY_BUILD) { - val now = System.currentTimeMillis() - val firstConn = - sharedPreferences.getLong("first_connection_successful_time", 0L) - - val alreadyPrompted = - sharedPreferences.getBoolean("review_prompted", false) + if (android.os.Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) + }.toTypedArray() - val oneDay = 24 * 60 * 60 * 1000L - - if ( - firstConn != 0L && - !alreadyPrompted && - (now - firstConn) > oneDay - ) { - triggerReviewFlow(context as? Activity ?: return@LaunchedEffect) - - sharedPreferences.edit { - putBoolean("review_prompted", true) - } - } - } + private fun hasAllPermissions(): Boolean = requiredPermissions().all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } - val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false) - - val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}" - val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false) - - fun bindService() { - context.startForegroundService(Intent(context, AirPodsService::class.java)) - serviceConnection = object: ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - val binder = service as AirPodsService.LocalBinder - val service = binder.getService() - airPodsService.value = service - airPodsViewModel.init( - service = service, - controlRepo = ControlCommandRepository(service.aacpManager), - sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE), - appContext = context.applicationContext - ) - - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong("first_connection_successful_time", System.currentTimeMillis()) - } - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - airPodsService.value = null - } - } - - context.bindService( - Intent(context, AirPodsService::class.java), - serviceConnection, - Context.BIND_AUTO_CREATE - ) + /** Open the actual Wear OS Bluetooth pairing/settings screen. */ + private fun openSystemBluetoothSettings() { + runCatching { startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + .onFailure { startActivity(Intent(Settings.ACTION_SETTINGS)) } } - if (onboardingComplete) { - bindService() + private fun refreshDevices() { + if (android.os.Build.VERSION.SDK_INT < 31 || + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + ) service?.scanner?.refreshPairedDevices() } - NavigationRoot( - showReleaseNotes = !releaseNotesShown, - updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } }, - showOnboarding = !onboardingComplete, - onboardingComplete = { - sharedPreferences.edit { putBoolean("onboarding_complete", true) } - bindService() - }, - airPodsViewModel = airPodsViewModel - ) -} + override fun onResume() { + super.onResume() + refreshDevices() + } -private fun triggerReviewFlow(activity: Activity) { - val manager = ReviewManagerFactory.create(activity) - val request = manager.requestReviewFlow() - request.addOnCompleteListener { task -> - if (task.isSuccessful) { - val reviewInfo = task.result - manager.launchReviewFlow(activity, reviewInfo) + override fun onDestroy() { + if (bound) { + unbindService(serviceConnection) + bound = false } + super.onDestroy() } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt deleted file mode 100644 index 2aa9158c3..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ /dev/null @@ -1,639 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection -import android.media.AudioManager -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import android.view.Gravity -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush -import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton -import me.kavishdevar.librepods.presentation.components.IconAreaSize -import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.NoiseControlMode -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.bluetooth.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs - -class QuickSettingsDialogActivity : ComponentActivity() { - - private var airPodsService: AirPodsService? = null - private var isBound = false - - private var isNoiseControlExpandedState by mutableStateOf(false) - - private val connection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as AirPodsService.LocalBinder - airPodsService = binder.getService() - isBound = true - Log.d("QSActivity", "Service bound") - setContent { - LibrePodsTheme { - DraggableDismissBox( - onDismiss = { finish() }, - onlyCollapseWhenClicked = { - if (isNoiseControlExpandedState) { - isNoiseControlExpandedState = false - true - } else { - false - } - } - ) { - if (isBound && airPodsService != null) { - NewControlCenterDialogContent( - service = airPodsService, - isNoiseControlExpanded = isNoiseControlExpandedState, - onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } - ) - } - } - } - } - } - - override fun onServiceDisconnected(arg0: ComponentName) { - isBound = false - airPodsService = null - Log.d("QSActivity", "Service unbound") - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) - window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH) - window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) - window.setGravity(Gravity.BOTTOM) - - Intent(this, AirPodsService::class.java).also { intent -> - bindService(intent, connection, BIND_AUTO_CREATE) - } - - setContent { - LibrePodsTheme { - DraggableDismissBox( - onDismiss = { finish() }, - onlyCollapseWhenClicked = { - if (isNoiseControlExpandedState) { - isNoiseControlExpandedState = false - true - } else { - false - } - } - ) { - if (isBound && airPodsService != null) { - NewControlCenterDialogContent( - service = airPodsService, - isNoiseControlExpanded = isNoiseControlExpandedState, - onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } - ) - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - if (isBound) { - unbindService(connection) - isBound = false - } - } -} - -@Composable -fun DraggableDismissBox( - onDismiss: () -> Unit, - onlyCollapseWhenClicked: () -> Boolean, - content: @Composable () -> Unit -) { - val coroutineScope = rememberCoroutineScope() - - var dragOffset by remember { mutableFloatStateOf(0f) } - var isDragging by remember { mutableStateOf(false) } - val dismissThreshold = 400f - - val animatedOffset = remember { Animatable(0f) } - val animatedScale = remember { Animatable(1f) } - val animatedAlpha = remember { Animatable(1f) } - - val backgroundAlpha by animateFloatAsState( - targetValue = if (isDragging) { - val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f) - 1f - dragProgress - } else 1f, - label = "BackgroundFade" - ) - - LaunchedEffect(isDragging) { - if (!isDragging) { - if (abs(dragOffset) < dismissThreshold) { - val springSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessHigh, - visibilityThreshold = 0.1f - ) - launch { animatedOffset.animateTo(0f, springSpec) } - launch { animatedScale.animateTo(1f, springSpec) } - launch { animatedAlpha.animateTo(1f, tween(100)) } - dragOffset = 0f - } - } - } - - LaunchedEffect(dragOffset, isDragging) { - if (isDragging) { - val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f) - - animatedOffset.snapTo(dragOffset) - animatedScale.snapTo(1f - dragProgress * 0.3f) - animatedAlpha.snapTo(1f - dragProgress * 0.7f) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f * backgroundAlpha)) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { isDragging = true }, - onDragEnd = { - isDragging = false - if (abs(dragOffset) > dismissThreshold) { - coroutineScope.launch { - val direction = if (dragOffset > 0) 1f else -1f - - launch { - animatedOffset.animateTo( - direction * 1500f, - tween(350, easing = FastOutSlowInEasing) - ) - } - launch { animatedScale.animateTo(0.7f, tween(350)) } - launch { animatedAlpha.animateTo(0f, tween(250)) } - - kotlinx.coroutines.delay(350) - onDismiss() - } - } - }, - onDragCancel = { isDragging = false }, - onVerticalDrag = { change, dragAmount -> - change.consume() - dragOffset += dragAmount - } - ) - } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - onlyCollapseWhenClicked() - }, - contentAlignment = Alignment.BottomCenter - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer( - translationY = animatedOffset.value, - scaleX = animatedScale.value, - scaleY = animatedScale.value, - alpha = animatedAlpha.value - ), - contentAlignment = Alignment.BottomCenter - ) { - content() - } - } -} - -@SuppressLint("UnspecifiedRegisterReceiverFlag") -@Composable -fun NewControlCenterDialogContent( - service: AirPodsService?, - isNoiseControlExpanded: Boolean, - onNoiseControlExpandedChange: (Boolean) -> Unit -) { - val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - val textColor = Color.White - - var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) } - var isConvAwarenessEnabled by remember { mutableStateOf(false) } - - val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) } - val availableModes = remember(isOffModeEnabled) { - mutableListOf( - NoiseControlMode.TRANSPARENCY, - NoiseControlMode.ADAPTIVE, - NoiseControlMode.NOISE_CANCELLATION - ).apply { - if (isOffModeEnabled) { - add(0, NoiseControlMode.OFF) - } - } - } - - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } - var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) } - val animatedVolumeFraction by animateFloatAsState( - targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(), - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow - ), - label = "VolumeAnimation" - ) - var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) } - var isDraggingVolume by remember { mutableStateOf(false) } - LaunchedEffect(animatedVolumeFraction, isDraggingVolume) { - if (!isDraggingVolume) { - liveDragFraction = animatedVolumeFraction - } - } - - DisposableEffect(service, availableModes) { - val ancReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == AirPodsNotifications.ANC_DATA && service != null) { - val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1 - val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY } - if (availableModes.contains(newMode)) { - currentAncMode = newMode - } else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) { - currentAncMode = NoiseControlMode.TRANSPARENCY - } - Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})") - } - } - } - val filter = IntentFilter(AirPodsNotifications.ANC_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(ancReceiver, filter) - } - - service?.let { - val initialModeOrdinal = it.getANC().minus(1) - var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY } - if (!availableModes.contains(initialMode)) { - initialMode = NoiseControlMode.TRANSPARENCY - } - currentAncMode = initialMode - isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true) - Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled") - } - - onDispose { - context.unregisterReceiver(ancReceiver) - } - } - - DisposableEffect(Unit) { - val volumeReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.media.VOLUME_CHANGED_ACTION") { - val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (newVolume != currentVolumeInt) { - currentVolumeInt = newVolume - Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt") - } - } - } - } - val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") - context.registerReceiver(volumeReceiver, filter) - onDispose { - context.unregisterReceiver(volumeReceiver) - } - } - - val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" } - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Transparent) - .padding(horizontal = 24.dp) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent() - } - } - }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween - ) { - if (service != null) { - Spacer(modifier = Modifier.weight(1f)) - - Column( - modifier = Modifier - .weight(2f) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(id = R.drawable.airpods), - contentDescription = "Device Icon", - tint = textColor.copy(alpha = 0.8f), - modifier = Modifier.size(48.dp) - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = deviceName, - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(32.dp)) - - VerticalVolumeSlider( - displayFraction = animatedVolumeFraction, - maxVolume = maxVolume, - onVolumeChange = { newVolume -> - currentVolumeInt = newVolume - try { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0) - } catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) } - }, - initialFraction = animatedVolumeFraction, - onDragStateChange = { dragging -> isDraggingVolume = dragging }, - baseSliderHeight = 400.dp, - baseSliderWidth = 145.dp, - baseCornerRadius = 48.dp, - maxStretchFactor = 1.15f, - minCompressionFactor = 0.875f, - stretchSensitivity = 0.3f, - compressionSensitivity = 0.3f, - cornerRadiusChangeFactor = -0.5f, - directionalStretchRatio = 0.75f, - modifier = Modifier - .width(145.dp) - .padding(vertical = 8.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 72.dp) - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ), - contentAlignment = Alignment.Center - ) { - Crossfade( - targetState = isNoiseControlExpanded, - animationSpec = tween(durationMillis = 300), - label = "NoiseControlCrossfade" - ) { expanded -> - if (expanded) { - ControlCenterNoiseControlSegmentedButton( - availableModes = availableModes, - selectedMode = currentAncMode, - onModeSelected = { newMode -> - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - value = newMode.ordinal + 1 - ) - currentAncMode = newMode - }, - modifier = Modifier.fillMaxWidth(0.8f) - ) - } else { - Row( - modifier = Modifier.fillMaxWidth(0.85f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) { - AdaptiveRainbowBrush - } else { - null - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(IconAreaSize) - .clip(CircleShape) - .background( - brush = noiseControlButtonBrush ?: - Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF))) - ) - .clickable( - onClick = { onNoiseControlExpandedChange(true) }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = getModeIconRes(currentAncMode)), - contentDescription = getModeLabel(currentAncMode), - tint = Color.White, - modifier = Modifier.size(32.dp) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = getModeLabel(currentAncMode), - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - } - - Spacer(modifier = Modifier.width(24.dp)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(IconAreaSize) - .clip(CircleShape) - .background( - Brush.linearGradient( - colors = listOf( - if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E), - if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E) - ) - ) - ) - .clickable( - onClick = { - val newState = !isConvAwarenessEnabled - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, - value = newState - ) - isConvAwarenessEnabled = newState - }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.airpods), - contentDescription = "Conversational Awareness", - tint = Color.White, - modifier = Modifier.size(32.dp) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Conversational\nAwareness", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - lineHeight = 14.sp - ) - } - } - } - } - } - - } else { - Spacer(modifier = Modifier.weight(1f)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text("Loading...", color = textColor) - } - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -private fun getModeIconRes(mode: NoiseControlMode): Int { - return when (mode) { - NoiseControlMode.OFF -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY -> R.drawable.transparency - NoiseControlMode.ADAPTIVE -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation - } -} - -private fun getModeLabel(mode: NoiseControlMode): String { - return when (mode) { - NoiseControlMode.OFF -> "Off" - NoiseControlMode.TRANSPARENCY -> "Transparency" - NoiseControlMode.ADAPTIVE -> "Adaptive" - NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel" - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt deleted file mode 100644 index cfea382a2..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt +++ /dev/null @@ -1,5 +0,0 @@ -package me.kavishdevar.librepods.billing - -object BillingManager { - lateinit var provider: BillingProvider -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt deleted file mode 100644 index 52a670512..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.billing - -import android.app.Activity -import kotlinx.coroutines.flow.StateFlow - -interface BillingProvider { - val isPremium: StateFlow - val price: StateFlow - fun purchase(activity: Activity) - fun queryPurchases() - fun restorePurchases() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt deleted file mode 100644 index 33f9b41f2..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.billing - -import android.content.Context -import me.kavishdevar.librepods.BuildConfig - -object BillingProviderFactory { - - fun create(context: Context): BillingProvider { - return if (BuildConfig.PLAY_BUILD) { - PlayBillingProvider(context) - } else { - FOSSBillingProvider(context) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt deleted file mode 100644 index 4f07db669..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.billing - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.content.edit -import androidx.core.net.toUri -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R - -class FOSSBillingProvider(context: Context): BillingProvider { - private val _isPremium = MutableStateFlow(false) - override val isPremium: StateFlow = _isPremium - - private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price)) - override val price: StateFlow = _price - - private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var purchaseJob: Job? = null - - init { - queryPurchases() - } - - override fun purchase(activity: Activity) { - activity.startActivity( - Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri()) - ) - - purchaseJob?.cancel() - - purchaseJob = scope.launch { - delay(5_000) - _isPremium.value = true - sharedPreferences.edit { putBoolean("foss_upgraded", true) } - } - } - - override fun queryPurchases() { - val stored = sharedPreferences.getBoolean("foss_upgraded", false) - if (stored != _isPremium.value) { - _isPremium.value = stored - } - } - - override fun restorePurchases() { - _isPremium.value = true - sharedPreferences.edit { putBoolean("foss_upgraded", true) } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt deleted file mode 100644 index 02f17b95e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt +++ /dev/null @@ -1,206 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.billing - -import android.app.Activity -import android.content.Context -import android.util.Log -import com.android.billingclient.api.AcknowledgePurchaseParams -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingClientStateListener -import com.android.billingclient.api.BillingFlowParams -import com.android.billingclient.api.BillingResult -import com.android.billingclient.api.PendingPurchasesParams -import com.android.billingclient.api.ProductDetails -import com.android.billingclient.api.Purchase -import com.android.billingclient.api.PurchasesUpdatedListener -import com.android.billingclient.api.QueryProductDetailsParams -import com.android.billingclient.api.QueryPurchasesParams -import com.android.billingclient.api.acknowledgePurchase -import com.android.billingclient.api.queryProductDetails -import com.android.billingclient.api.queryPurchasesAsync -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -const val TAG = "PlayBillingProvider" - -private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2" - -class PlayBillingProvider( - context: Context -) : BillingProvider, PurchasesUpdatedListener { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val _isPremium = MutableStateFlow(false) - override val isPremium: StateFlow = _isPremium - - private val _price = MutableStateFlow("unknown") - override val price: StateFlow = _price - - - private var productDetails: ProductDetails? = null - - private val billingClient = BillingClient.newBuilder(context) - .setListener(this) - .enablePendingPurchases( - PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() - ) - .build() - - init { - connect() - } - - private fun connect() { - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(result: BillingResult) { - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - scope.launch { - queryProductDetails() - queryExistingPurchases() - } - } else { - Log.w(TAG, "Billing setup failed: ${result.debugMessage}") - } - } - - override fun onBillingServiceDisconnected() { - connect() - } - }) - } - - private suspend fun queryProductDetails() { - val params = QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId(PREMIUM_PRODUCT_ID) - .setProductType(BillingClient.ProductType.INAPP) - .build() - ) - ).build() - - val result = billingClient.queryProductDetails(params) - if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - productDetails = result.productDetailsList?.firstOrNull() - Log.d(TAG, "Product loaded: ${productDetails?.name}") - val priceString = productDetails - ?.oneTimePurchaseOfferDetails - ?.formattedPrice - - if (priceString != null) { - _price.value = priceString - } - } else { - Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}") - } - } - - private suspend fun queryExistingPurchases() { - val result = billingClient.queryPurchasesAsync( - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build() - ) - processPurchases(result.purchasesList) - } - - override fun purchase(activity: Activity) { - val details = productDetails ?: run { - Log.e(TAG, "Product details not loaded yet") - return - } - - val billingFlowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(details) - .build() - ) - ).build() - - val result = billingClient.launchBillingFlow(activity, billingFlowParams) - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}") - } - } - - override fun onPurchasesUpdated(result: BillingResult, purchases: List?) { - when (result.responseCode) { - BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) } - BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled") - else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}") - } - } - - private fun processPurchases(purchases: List) { - val hasPremium = purchases.any { - it.products.contains(PREMIUM_PRODUCT_ID) && - it.purchaseState == Purchase.PurchaseState.PURCHASED - } - -// val purchase = purchases.find { -// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED -// } -// -// if (purchase != null) { -// val consumeParams = ConsumeParams.newBuilder() -// .setPurchaseToken(purchase.purchaseToken) -// .build() -// scope.launch { -// billingClient.consumeAsync(consumeParams) { _, _ ->} -// } -// } - - _isPremium.value = hasPremium - - scope.launch { - purchases - .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged } - .forEach { acknowledge(it) } - } - } - - private suspend fun acknowledge(purchase: Purchase) { - val params = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - val result = billingClient.acknowledgePurchase(params) - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}") - } - } - - override fun queryPurchases() { - scope.launch { - queryExistingPurchases() - } - } - - override fun restorePurchases() { - queryPurchases() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index ac6d356b7..3c5617dfd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -6,14 +6,6 @@ it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . */ @file:OptIn(ExperimentalEncodingApi::class) @@ -23,19 +15,31 @@ package me.kavishdevar.librepods.bluetooth import android.util.Log import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.CustomEq -import java.nio.ByteBuffer -import java.nio.ByteOrder +import me.kavishdevar.librepods.wear.bluetooth.AirPodsProtocolTransport import kotlin.io.encoding.ExperimentalEncodingApi -/** - * Manager class for Apple Accessory Communication Protocol (AACP) - * This class is responsible for handling the L2CAP socket management, - * constructing and parsing packets for communication with AirPods. - */ +/** Wear-facing AACP packet engine. */ class AACPManager { - private val TAG = "AACPManager[${System.identityHashCode(this)}]" + private val tag = "AACPManager[${System.identityHashCode(this)}]" + private var transport: AirPodsProtocolTransport? = null + + enum class SessionState { IDLE, HANDSHAKE_SENT, FEATURES_SENT, READY } + var sessionState: SessionState = SessionState.IDLE + private set + + fun bindTransport(protocolTransport: AirPodsProtocolTransport) { + transport = protocolTransport + sessionState = SessionState.IDLE + Log.d(tag, "AACP transport bound") + } + + fun unbindTransport() { + transport = null + sessionState = SessionState.IDLE + Log.d(tag, "AACP transport unbound") + } + companion object { - @Suppress("unused") object Opcodes { const val SET_FEATURE_FLAGS: Byte = 0x4D const val REQUEST_NOTIFICATIONS: Byte = 0x0F @@ -50,184 +54,72 @@ class AACPManager { const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val STEM_PRESS: Byte = 0x19 const val HEADPHONE_ACCOMMODATION: Byte = 0x53 - const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1 - const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2 + const val CONNECTED_DEVICES: Byte = 0x2E + const val AUDIO_SOURCE: Byte = 0x0E const val SMART_ROUTING: Byte = 0x10 - const val TIPI_3: Byte = 0x0C // Don't know this one + const val TIPI_3: Byte = 0x0C const val SMART_ROUTING_RESP: Byte = 0x11 const val SEND_CONNECTED_MAC: Byte = 0x14 - const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant? + const val AUDIO_SOURCE_2: Byte = 0x0C const val CUSTOM_EQ: Byte = 0x63 } - private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) + private val header = byteArrayOf(0x04, 0x00, 0x04, 0x00) + private val handshake = byteArrayOf( + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ) data class ControlCommandStatus( - val identifier: ControlCommandIdentifiers, val value: ByteArray + val identifier: ControlCommandIdentifiers, + val value: ByteArray, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ControlCommandStatus - - if (identifier != other.identifier) return false - if (!value.contentEquals(other.value)) return false - - return true - } - - override fun hashCode(): Int { - var result: Int = identifier.hashCode() - result = 31 * result + value.contentHashCode() - return result + return identifier == other.identifier && value.contentEquals(other.value) } + override fun hashCode(): Int = 31 * identifier.hashCode() + value.contentHashCode() } - // @Suppress("unused") enum class ControlCommandIdentifiers(val value: Byte) { - MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE( - 0x15 - ), - CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS( - 0x1A - ), - ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE( - 0x1E - ), - CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE( - 0x25 - ), - ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG( - 0x28 - ), - SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE( - 0x30 - ), - IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION( - 0x34 - ), - STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ - EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG( - 0x37 - ), - PPE_CAP_LEVEL_CONFIG(0x38), - DYNAMIC_END_OF_CHARGE(0x3B); - - companion object { - fun fromByte(byte: Byte): ControlCommandIdentifiers? = - entries.find { it.value == byte } - } + MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), + DOUBLE_CLICK_MODE(0x15), CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), + CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(0x1A), ONE_BUD_ANC_MODE(0x1B), + CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(0x1E), + CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), + VOLUME_SWIPE_MODE(0x25), ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), + CONVERSATION_DETECT_CONFIG(0x28), SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), + HPS_GAIN_SWIPE(0x2F), HRM_STATE(0x30), IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), + HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(0x34), STEM_CONFIG(0x39), + SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), PPE_TOGGLE_CONFIG(0x37), + PPE_CAP_LEVEL_CONFIG(0x38), DYNAMIC_END_OF_CHARGE(0x3B), EAR_DETECTION_CONFIG(0x0A), + AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06); + companion object { fun fromByte(byte: Byte): ControlCommandIdentifiers? = entries.find { it.value == byte } } } enum class ProximityKeyType(val value: Byte) { IRK(0x01), ENC_KEY(0x04); - - companion object { - fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte } - ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") - } + companion object { fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte } ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") } } - - enum class StemPressType(val value: Byte) { - SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08); - - companion object { - fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte } - } - } - - enum class StemPressBudType(val value: Byte) { - LEFT(0x01), RIGHT(0x02); - - companion object { - fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte } - } - } - - enum class AudioSourceType(val value: Byte) { - NONE(0x00), CALL(0x01), MEDIA(0x02); - - companion object { - fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte } - } - } - - data class AudioSource( - val mac: String, val type: AudioSourceType - ) - - data class ConnectedDevice( - val mac: String, val info1: Byte, val info2: Byte, var type: String? - ) - + enum class StemPressType(val value: Byte) { SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08) } + enum class StemPressBudType(val value: Byte) { LEFT(0x01), RIGHT(0x02) } + enum class AudioSourceType(val value: Byte) { NONE(0x00), CALL(0x01), MEDIA(0x02) } + data class AudioSource(val mac: String, val type: AudioSourceType) + data class ConnectedDevice(val mac: String, val info1: Byte, val info2: Byte, var type: String?) data class AirPodsInformation( - val name: String, - val modelNumber: String, - val manufacturer: String, - val serialNumber: String, - val version1: String, - val version2: String, - val hardwareRevision: String, - val updaterIdentifier: String, - val leftSerialNumber: String, - val rightSerialNumber: String, - val version3: String + val name: String, val modelNumber: String, val manufacturer: String, val serialNumber: String, + val version1: String, val version2: String, val hardwareRevision: String, + val updaterIdentifier: String, val leftSerialNumber: String, val rightSerialNumber: String, val version3: String, ) } - var controlCommandStatusList: MutableList = - mutableListOf() - var controlCommandListeners: MutableMap> = - mutableMapOf() - + var controlCommandStatusList: MutableList = mutableListOf() + var controlCommandListeners: MutableMap> = mutableMapOf() var owns: Boolean = false private set - var oldConnectedDevices: List = listOf() - private set - - var connectedDevices: List = listOf() - private set - - var audioSource: AudioSource? = null - private set - - var eqData = FloatArray(8) - private set - - var eqOnPhone: Boolean = false - private set - - var eqOnMedia: Boolean = false - private set - - var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50) - private set - - var customEqCallback: ((CustomEq) -> Unit)? = null - - fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { - return controlCommandStatusList.find { it.identifier == identifier } - } - - private fun setControlCommandStatusValue( - identifier: ControlCommandIdentifiers, value: ByteArray - ) { - val existingStatus = getControlCommandStatus(identifier) - if (existingStatus?.value.contentEquals(value)) { - controlCommandStatusList.remove(existingStatus) - } - controlCommandListeners[identifier]?.forEach { listener -> - listener.onControlCommandReceived(ControlCommand(identifier.value, value)) - } - controlCommandStatusList.add(ControlCommandStatus(identifier, value)) - - if (identifier == ControlCommandIdentifiers.OWNS_CONNECTION) { - owns = value.isNotEmpty() && value[0] == 0x01.toByte() - } - } - interface PacketCallback { fun onBatteryInfoReceived(batteryInfo: ByteArray) fun onEarDetectionReceived(earDetection: ByteArray) @@ -248,1103 +140,117 @@ class AACPManager { fun onCapabilitiesReceived(capabilities: List) } - fun parseStemPressResponse(data: ByteArray): Pair { - Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}") - if (data.size != 8) { - throw IllegalArgumentException("Data array too short to parse Stem Press Response") - } - if (data[4] != Opcodes.STEM_PRESS) { - throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode") - } - val type = StemPressType.fromByte(data[6]) - ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}") - val bud = StemPressBudType.fromByte(data[7]) - ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}") - return Pair(type, bud) - } - - interface ControlCommandListener { - fun onControlCommandReceived(controlCommand: ControlCommand) - } - - fun registerControlCommandListener( - identifier: ControlCommandIdentifiers, callback: ControlCommandListener - ) { - controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) - } - - fun unregisterControlCommandListener( - identifier: ControlCommandIdentifiers, callback: ControlCommandListener - ) { - controlCommandListeners[identifier]?.remove(callback) - } - + interface ControlCommandListener { fun onControlCommandReceived(controlCommand: ControlCommand) } private var callback: PacketCallback? = null + fun setPacketCallback(callback: PacketCallback) { this.callback = callback } - fun setPacketCallback(callback: PacketCallback) { - this.callback = callback - } - - fun createDataPacket(data: ByteArray): ByteArray { - return HEADER_BYTES + data - } - - fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray { - val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00) - val payload = ByteArray(7) - - System.arraycopy(opcode, 0, payload, 0, 2) - payload[2] = identifier - - val dataLength = minOf(data.size, 4) - System.arraycopy(data, 0, payload, 3, dataLength) - - return payload - } - - fun sendDataPacket(data: ByteArray): Boolean { - return sendPacket(createDataPacket(data)) - } - - fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { - val controlPacket = createControlCommandPacket(identifier, value) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, value - ) - return sendDataPacket(controlPacket) - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendControlCommand(identifier: Byte, value: Byte): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value) - ) - return sendDataPacket(controlPacket) - } - - fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { - val controlPacket = createControlCommandPacket( - identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - return sendDataPacket(controlPacket) - } - - fun sendControlCommand(identifier: Byte, value: Int): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte())) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - byteArrayOf(value.toByte()) - ) - return sendDataPacket(controlPacket) - } - - fun parseProximityKeysResponse(data: ByteArray): Map { - Log.d( - TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}" - ) - if (data.size < 4) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) { - throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode") - } - val keyCount = data[6].toInt() - val keys = mutableMapOf() - var offset = 7 - for (i in 0 until keyCount) { - Log.d(TAG, "Parsing Proximity Key $i") - if (offset + 3 >= data.size) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - val keyType = data[offset] - val keyLength = data[offset + 2].toInt() - Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength") - offset += 4 - if (offset + keyLength > data.size) { - throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") - } - val key = ByteArray(keyLength) - System.arraycopy(data, offset, key, 0, keyLength) - try { - keys[ProximityKeyType.fromByte(keyType)] = key - } catch (e: Exception) { - Log.e( - TAG, "incorrect key type received: $keyType, ${key.toHexString()}" - ) - } - offset += keyLength - Log.d( - TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ - key.joinToString(" ") { "%02X".format(it) } - }") + /** Start only the first handshake step. The remaining steps are driven by ACK packets. */ + fun startSession(): Boolean { + if (sessionState != SessionState.IDLE) return sessionState == SessionState.READY + val sent = sendRaw(handshake) + if (sent) { + sessionState = SessionState.HANDSHAKE_SENT + Log.i(tag, "AACP handshake sent; waiting for handshake ACK") } - return keys + return sent } - fun sendRequestProximityKeys(type: Byte): Boolean { - Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}") - return sendDataPacket(createRequestProximityKeysPacket(type)) - } - - fun createRequestProximityKeysPacket(type: Byte): ByteArray { - val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00) - val data = byteArrayOf(type, 0x00) - return opcode + data - } + private fun createSetFeatureFlagsPacket(): ByteArray = createDataPacket( + byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00, 0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + ) - @OptIn(ExperimentalStdlibApi::class) - fun receivePacket(packet: ByteArray) { - if (!packet.toHexString().startsWith("04000400")) { - Log.w( - TAG, "Received packet does not start with expected header: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }") - return - } - if (packet.size < 6) { - Log.w( - TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" - ) - return - } - - when (val opcode = packet[4]) { - Opcodes.BATTERY_INFO -> { - callback?.onBatteryInfoReceived(packet) - } - - Opcodes.CONTROL_COMMAND -> { - val controlCommand = try { - ControlCommand.fromByteArray(packet) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse control command: ${e.message}") - callback?.onUnknownPacketReceived(packet) - return - } - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, - controlCommand.value - ) - Log.d( - TAG, - "Control command received: ${controlCommand.identifier.toHexString()} - ${ - controlCommand.value.joinToString(" ") { "%02X".format(it) } - }") - - val controlCommandListText = try { - controlCommandStatusList.joinToString(", ") { it -> - "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ - it.value.joinToString( - " " - ) { "%02X".format(it) } - }" - } - } catch (e: Exception) { - e.message - } - - Log.d( - TAG, "Control command list is now: $controlCommandListText" - ) - - val controlCommandIdentifier = - ControlCommandIdentifiers.fromByte(controlCommand.identifier) - if (controlCommandIdentifier != null) { - controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> - Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}") - listener.onControlCommandReceived(controlCommand) - } - } else { - Log.w( - TAG, - "Unknown control command identifier: ${controlCommand.identifier.toHexString()}" - ) - } - - if (controlCommandIdentifier == ControlCommandIdentifiers.OWNS_CONNECTION) { - callback?.onOwnershipChangeReceived(owns) - } - - callback?.onControlCommandReceived(packet) - } - - Opcodes.EAR_DETECTION -> { - callback?.onEarDetectionReceived(packet) - } - - Opcodes.CONVERSATION_AWARENESS -> { - callback?.onConversationAwarenessReceived(packet) - } - - Opcodes.HEADTRACKING -> { - if (packet.size < 70) { - Log.w( - TAG, "Received HEADTRACKING packet too short: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }") - return - } - callback?.onHeadTrackingReceived(packet) - } - - Opcodes.PROXIMITY_KEYS_RSP -> { - callback?.onProximityKeysReceived(packet) - } - - Opcodes.STEM_PRESS -> { - callback?.onStemPressReceived(packet) - } - - Opcodes.AUDIO_SOURCE -> { - try { - val (mac, type) = parseAudioSourceResponse(packet) - audioSource = AudioSource(mac, type) - } catch (e: Exception) { - Log.e(TAG, "Error parsing audio source response: ${e.message}") - } - callback?.onAudioSourceReceived(packet) - } - - Opcodes.CONNECTED_DEVICES -> { - oldConnectedDevices = connectedDevices - connectedDevices = parseConnectedDevicesResponse(packet) - callback?.onConnectedDevicesReceived(connectedDevices) - } - - Opcodes.SMART_ROUTING_RESP -> { - val packetString = packet.decodeToString() - val sender = - packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } - - // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { - // val nameStartIndex = packetString.indexOf("btName") + 8 - // val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 1) else (packetString.indexOf("nearbyAudio") - 1) - // val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString() - // connectedDevices.find { it.mac == sender }?.type = name - // Log.d(TAG, "Device $sender is named $name") - // } // doesn't work, it's different for Mac and iPad. just hardcoding for now - if ("iPad" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "iPad" - } else if ("Mac" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Mac" - } else if ("iPhone" in packetString) { // not sure if this is it - don't have an iphone - connectedDevices.find { it.mac == sender }?.type = "iPhone" - } else if ("Linux" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Linux" - } else if ("Android" in packetString) { - connectedDevices.find { it.mac == sender }?.type = "Android" - } - Log.d( - TAG, - "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}" - ) - if (packetString.contains("SetOwnershipToFalse")) { - callback?.onOwnershipToFalseRequest( - sender, - packetString.contains("ReverseBannerTapped") - ) - } - if (packetString.contains("ShowNearbyUI")) { - callback?.onShowNearbyUI(sender) - } - } - - Opcodes.HEADPHONE_ACCOMMODATION -> { - if (packet.size != 140) { - Log.w( - TAG, - "Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140" - ) - return - } - if (packet[6] != 0x84.toByte()) { - Log.w( - TAG, - "Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84" - ) - return - } - - eqOnMedia = (packet[10] == 0x01.toByte()) - eqOnPhone = (packet[11] == 0x01.toByte()) - // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird. - // the EQs are little endian floats - val eq1 = - ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - - // for now, taking just the first EQ - eqData = FloatArray(8) { i -> eq1.get(i) } - - Log.d( - TAG, - "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" - ) - - callback?.onHeadphoneAccommodationReceived(eqData) - } - - Opcodes.INFORMATION -> { - Log.d(TAG, "Parsing Information Packet") - val information = parseInformationPacket(packet) - callback?.onDeviceInformationReceived(information) - } - - Opcodes.CUSTOM_EQ -> { - Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}") - customEq = parseCustomEqPacket(packet) - customEqCallback?.invoke(customEq) - callback?.onCustomEqReceived(customEq) - } - - else -> { - Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") - callback?.onUnknownPacketReceived(packet) - } + private fun sendSetFeatureFlags(): Boolean { + val sent = sendRaw(createSetFeatureFlagsPacket()) + if (sent) { + sessionState = SessionState.FEATURES_SENT + Log.i(tag, "AACP feature flags sent; waiting for features ACK") } + return sent } + fun createDataPacket(data: ByteArray): ByteArray = header + data + fun createRequestNotificationPacket(): ByteArray = createDataPacket( + byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + ) fun sendNotificationRequest(): Boolean { - return sendDataPacket(createRequestNotificationPacket()) - } - - fun createRequestNotificationPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00) - val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) - // note to self #1: third byte is 0xfd when ear detection is disabled - // note to self #2: this can be sent any time, not just at the start of the aacp connection - return opcode + data - } - - fun sendSetFeatureFlagsPacket(): Boolean { - return sendDataPacket(createSetFeatureFlagsPacket()) - } - - fun createSetFeatureFlagsPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) - val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - return opcode + data - } - - fun createHandshakePacket(): ByteArray { - return byteArrayOf( - 0x00, - 0x00, - 0x04, - 0x00, - 0x01, - 0x00, - 0x02, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00 - ) - } - - fun sendStartHeadTracking(): Boolean { - return sendDataPacket(createStartHeadTrackingPacket()) - } - - fun createStartHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x10, - 0x00, - 0x08, - 0xA1.toByte(), - 0x02, - 0x42, - 0x0B, - 0x08, - 0x0E, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x40, - 0x9C.toByte(), - 0x00, - 0x00, - ) - return opcode + data - } - - fun createAlternateStartHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x0F, - 0x00, - 0x08, - 0x73, - 0x42, - 0x0B, - 0x08, - 0x10, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x40, - 0x9C.toByte(), - 0x00, - 0x00 - ) - return opcode + data - } - - fun sendStopHeadTracking(): Boolean { - return sendDataPacket(createStopHeadTrackingPacket()) - } - - fun createStopHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x11, - 0x00, - 0x08, - 0x7E, - 0x10, - 0x02, - 0x42, - 0x0B, - 0x08, - 0x4E, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00 - ) - return opcode + data - } - - fun createAlternateStopHeadTrackingPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) - val data = byteArrayOf( - 0x00, - 0x00, - 0x10, - 0x00, - 0x0F, - 0x00, - 0x08, - 0x75, - 0x42, - 0x0B, - 0x08, - 0x10, - 0x10, - 0x02, - 0x1A, - 0x05, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00 - ) - return opcode + data - } - - fun sendRename(name: String): Boolean { - return sendDataPacket(createRenamePacket(name)) - } - - fun createRenamePacket(name: String): ByteArray { - val nameBytes = name.toByteArray() - val size = nameBytes.size - val packet = ByteArray(5 + size) - packet[0] = Opcodes.RENAME - packet[1] = 0x00 - packet[2] = 0x01 - packet[3] = size.toByte() - packet[4] = 0x00 - System.arraycopy(nameBytes, 0, packet, 5, size) - - return packet - } - - fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches( - Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") - ) - ) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w( - TAG, - "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" - ) - return false + val sent = sendRaw(createRequestNotificationPacket()) + if (sent) { + sessionState = SessionState.READY + Log.i(tag, "AACP notifications requested; session READY") } - Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") - Log.d(TAG, "Sending Media Information packet to $targetMacAddress") - return sendDataPacket( - createMediaInformationNewDevicePacket( - selfMacAddress, - targetMacAddress - ) - ) + return sent } + fun sendDataPacket(data: ByteArray): Boolean = sendPacket(createDataPacket(data)) + fun sendPacket(packet: ByteArray): Boolean = sendRaw(packet) - fun createMediaInformationNewDevicePacket( - selfMacAddress: String, - targetMacAddress: String - ): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(116) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x6C, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) - buffer.put("playingApp".toByteArray()) - buffer.put(0x42) - buffer.put("NA".toByteArray()) - buffer.put(0x52) - buffer.put("hostStreamingState".toByteArray()) - buffer.put(0x42) - buffer.put("NO".toByteArray()) - buffer.put(0x49) - buffer.put("btAddress".toByteArray()) - buffer.put(0x51) - buffer.put(selfMacAddress.toByteArray()) - buffer.put(0x46) - buffer.put("btName".toByteArray()) - buffer.put(0x47) - buffer.put("Android".toByteArray()) - buffer.put(0x58) - buffer.put("otherDevice".toByteArray()) - buffer.put("AudioCategory".toByteArray()) - buffer.put(byteArrayOf(0x30, 0x64)) - - return opcode + buffer.array() - } - - fun sendHijackRequest(selfMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") + private fun sendRaw(packet: ByteArray): Boolean = try { + val output = transport?.aacpOutput ?: run { + Log.e(tag, "Cannot send AACP packet: Wear transport is not bound") return false } - var success = false - for (connectedDevice in connectedDevices) { - if (connectedDevice.mac != selfMacAddress) { - Log.d(TAG, "Sending Hijack Request packet to ${connectedDevice.mac}") - success = sendDataPacket(createHijackRequestPacket(connectedDevice.mac)) || success - } - } - return success - } - - fun createHijackRequestPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(106) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x62, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte())) - buffer.put(0x4A) - buffer.put("localscore".toByteArray()) - buffer.put(byteArrayOf(0x30, 0x64)) - buffer.put(0x46) - buffer.put("reason".toByteArray()) - buffer.put(0x48) - buffer.put("Hijackv2".toByteArray()) - buffer.put(0x51) - buffer.put("audioRoutingScore".toByteArray()) - buffer.put(byteArrayOf(0x31, 0x2D, 0x01, 0x5F)) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x4B) - buffer.put("remotescore".toByteArray()) - buffer.put(0xA5.toByte()) - - return opcode + buffer.array() + output.write(packet) + output.flush() + true + } catch (error: Exception) { + Log.e(tag, "Error sending AACP packet", error) + false } - fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") - return false - } - Log.d(TAG, "SELFMAC: $selfMacAddress") - val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac - if (targetMac == null) { - Log.w(TAG, "Cannot send Media Information packet: No connected device found") - return false - } - Log.d(TAG, "Sending Media Information packet to $targetMac") - return sendDataPacket( - createMediaInformationPacket( - selfMacAddress, targetMac, streamingState - ) - ) + fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray { + val payload = ByteArray(7) + payload[0] = Opcodes.CONTROL_COMMAND + payload[2] = identifier + System.arraycopy(data, 0, payload, 3, minOf(data.size, 4)) + return payload } - - fun createMediaInformationPacket( - selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true - ): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(138) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put( - byteArrayOf( - 0x82.toByte(), // related to the length - 0x00 - ) - ) - buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant - buffer.put("PlayingApp".toByteArray()) - buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator - buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason - buffer.put(byteArrayOf(0x52)) // 'R' - buffer.put("HostStreamingState".toByteArray()) - buffer.put(byteArrayOf(0x42)) // 'B' - buffer.put((if (streamingState) "YES" else "NO").toByteArray()) // streaming state - buffer.put(0x49) // 'I' - buffer.put("btAddress".toByteArray()) // self MAC - buffer.put(0x51) // 'Q' - buffer.put(selfMacAddress.toByteArray()) // self MAC - buffer.put("btName".toByteArray()) // self name - buffer.put(0x47) // 'D' - buffer.put("Android".toByteArray()) // if set to iPad, shows "Moved to iPad", but most likely we're running on a phone. setting to anything else of the same length will show iPhone instead. - buffer.put(0x58) // 'X' - buffer.put("otherDevice".toByteArray()) - buffer.put("AudioCategory".toByteArray()) - buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) - - return opcode + buffer.array() + fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { + val id = ControlCommandIdentifiers.fromByte(identifier) ?: return false + controlCommandStatusList.add(ControlCommandStatus(id, value)) + return sendDataPacket(createControlCommandPacket(identifier, value)) } - fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress") - return false - } - - val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac - if (targetMac == null) { - Log.w(TAG, "Cannot send Smart Routing Show UI packet: No connected device found") - return false + /** Feed one AACP L2CAP payload. ACKs advance the strict handshake state machine. */ + fun receivePacket(packet: ByteArray) { + if (packet.size >= 4 && packet[0] == 0x01.toByte() && packet[1] == 0x00.toByte() && packet[2] == 0x04.toByte() && packet[3] == 0x00.toByte()) { + if (sessionState == SessionState.HANDSHAKE_SENT) { + Log.i(tag, "AACP handshake ACK received") + sendSetFeatureFlags() + } else callback?.onUnknownPacketReceived(packet) + return } - Log.d(TAG, "Sending Smart Routing Show UI packet to $targetMac") - return sendDataPacket(createSmartRoutingShowUIPacket(targetMac)) - } - - fun createSmartRoutingShowUIPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(134) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x7E, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE6.toByte(), 0x5B)) - buffer.put("SmartRoutingKeyShowNearbyUI".toByteArray()) - buffer.put(0x01) // separator? - buffer.put(0x4A) - buffer.put("localscore".toByteArray()) - buffer.put(0x31, 0x2D) - buffer.put(0x01) - buffer.put(0x46) - buffer.put("reasonHhijackv2".toByteArray()) - buffer.put(0x51.toByte()) - buffer.put("audioRoutingScore".toByteArray()) - buffer.put(0xA2.toByte()) - buffer.put(0x5F) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x4B) - buffer.put("remotescore".toByteArray()) - buffer.put(0xA2.toByte()) - return opcode + buffer.array() - } - - fun sendHijackReversed(selfMacAddress: String): Boolean { - var success = false - for (connectedDevice in connectedDevices) { - if (connectedDevice.mac != selfMacAddress) { - Log.d(TAG, "Sending Hijack Reversed packet to ${connectedDevice.mac}") - success = sendDataPacket(createHijackReversedPacket(connectedDevice.mac)) || success + if (packet.size >= 6 && packet.copyOfRange(0, 4).contentEquals(header)) { + when (packet[4]) { + 0x2B.toByte() -> if (sessionState == SessionState.FEATURES_SENT) { + Log.i(tag, "AACP features ACK received") + sendNotificationRequest() + } else callback?.onUnknownPacketReceived(packet) + Opcodes.BATTERY_INFO -> callback?.onBatteryInfoReceived(packet) + Opcodes.EAR_DETECTION -> callback?.onEarDetectionReceived(packet) + Opcodes.CONTROL_COMMAND -> runCatching { + val command = ControlCommand.fromByteArray(packet) + ControlCommandIdentifiers.fromByte(command.identifier)?.let { id -> controlCommandStatusList.add(ControlCommandStatus(id, command.value)) } + callback?.onControlCommandReceived(packet) + }.onFailure { callback?.onUnknownPacketReceived(packet) } + else -> callback?.onUnknownPacketReceived(packet) } + return } - return success - } - - fun createHijackReversedPacket(targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(97) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x59, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE3.toByte())) - buffer.put(0x5F) - buffer.put("audioRoutingSetOwnershipToFalse".toByteArray()) - buffer.put(0x01) - buffer.put(0x59) - buffer.put("audioRoutingShowReverseUI".toByteArray()) - buffer.put(0x01) - buffer.put(0x46) - buffer.put("reason".toByteArray()) - buffer.put(0x53) - buffer.put("ReverseBannerTapped".toByteArray()) - - return opcode + buffer.array() - } - - - fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches( - Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") - ) - ) { - // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w( - TAG, - "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" - ) - return false - } - Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") - return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress)) + callback?.onUnknownPacketReceived(packet) } - fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { - val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(90) - buffer.put( - targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() - ) - buffer.put(byteArrayOf(0x52, 0x00)) - buffer.put(byteArrayOf(0x01, 0xE5.toByte())) - buffer.put(0x48) // 'H' - buffer.put("idleTime".toByteArray()) - buffer.put(byteArrayOf(0x08, 0x47)) - buffer.put("newTipi".toByteArray()) - buffer.put(byteArrayOf(0x01, 0x49)) - buffer.put("btAddress".toByteArray()) - buffer.put(0x51) - buffer.put(selfMacAddress.toByteArray()) - buffer.put(0x46) - buffer.put("btName".toByteArray()) - buffer.put(0x47) - buffer.put("Android".toByteArray()) - buffer.put(0x50) - buffer.put("nearbyAudioScore".toByteArray()) - buffer.put(byteArrayOf(0x0E)) - return opcode + buffer.array() - } - - data class ControlCommand( - val identifier: Byte, val value: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ControlCommand - - if (identifier != other.identifier) return false - if (!value.contentEquals(other.value)) return false - - return true - } - - override fun hashCode(): Int { - var result: Int = identifier.toInt() - result = 31 * result + value.contentHashCode() - return result - } + fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) { controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) } + fun unregisterControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) { controlCommandListeners[identifier]?.remove(callback) } + data class ControlCommand(val identifier: Byte, val value: ByteArray) { companion object { fun fromByteArray(data: ByteArray): ControlCommand { var offset = 0 - while (data.size - offset >= 4 && - data[offset] == 0x04.toByte() && - data[offset + 1] == 0x00.toByte() && - data[offset + 2] == 0x04.toByte() && - data[offset + 3] == 0x00.toByte() - ) { - offset += 4 - } - if (data.size - offset < 7) { - throw IllegalArgumentException("Too short for ControlCommand") - } - if (data[offset] != Opcodes.CONTROL_COMMAND) { - throw IllegalArgumentException("Invalid opcode") - } + while (data.size - offset >= 4 && data[offset] == 0x04.toByte() && data[offset + 1] == 0x00.toByte() && data[offset + 2] == 0x04.toByte() && data[offset + 3] == 0x00.toByte()) offset += 4 + if (data.size - offset < 7 || data[offset] != Opcodes.CONTROL_COMMAND) throw IllegalArgumentException("Invalid ControlCommand packet") val identifier = data[offset + 2] val value = data.copyOfRange(offset + 3, offset + 7) - val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray() - return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed) - } - } - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendStemConfigPacket( - singlePressCustomized: Boolean = false, - doublePressCustomized: Boolean = false, - triplePressCustomized: Boolean = false, - longPressCustomized: Boolean = false - ): Boolean { - val value = - ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte() - Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") - return sendControlCommand( - ControlCommandIdentifiers.STEM_CONFIG.value, value - ) - } - - @OptIn(ExperimentalStdlibApi::class) - fun sendPacket(packet: ByteArray): Boolean { - try { - Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") - - if (packet[4] == Opcodes.CONTROL_COMMAND) { - val controlCommand = try { - ControlCommand.fromByteArray(packet) - } catch (e: Exception) { - Log.w(TAG, "Invalid control command: ${e.message}") - callback?.onUnknownPacketReceived(packet) - return false - } - Log.d( - TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ - controlCommand.value.joinToString(" ") { "%02X".format(it) } - }") - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, - controlCommand.value - ) - } - - val socket = BluetoothConnectionManager.aacpSocket ?: return false - - if (socket.isConnected) { - socket.outputStream?.write(packet) - socket.outputStream?.flush() - return true - } else { - Log.d(TAG, "Can't send packet: Socket not initialized or connected") - return false - } - } catch (e: Exception) { - Log.e(TAG, "Error sending packet: ${e.message}") - return false - } - } - - fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) { - if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats") - val header = byteArrayOf( - 0x04.toByte(), - 0x00.toByte(), - 0x04.toByte(), - 0x00.toByte(), - 0x53.toByte(), - 0x00.toByte(), - 0x84.toByte(), - 0x00.toByte(), - 0x02.toByte(), - 0x02.toByte(), - phone, - media - ) - val buffer = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) - for (block in 0..3) { - for (i in 0..7) { - buffer.putFloat(eq[i]) - } - } - val payload = buffer.array() - val packet = header + payload - sendPacket(packet) - this.eqData = eq.copyOf() - this.eqOnPhone = phone == 0x01.toByte() - this.eqOnMedia = media == 0x01.toByte() - } - - fun parseAudioSourceResponse(data: ByteArray): Pair { - Log.d(TAG, "Parsing Audio Source Response: ${data.joinToString(" ") { "%02X".format(it) }}") - if (data.size < 9) { - throw IllegalArgumentException("Data array too short to parse Audio Source Response") - } - if (data[4] != Opcodes.AUDIO_SOURCE) { - throw IllegalArgumentException("Data array does not start with AUDIO_SOURCE opcode") - } - val macBytes = data.sliceArray(6..11).reversedArray() - val mac = macBytes.joinToString(":") { "%02X".format(it) } - val typeByte = data[12] - val type = AudioSourceType.fromByte(typeByte) - ?: throw IllegalArgumentException("Unknown Audio Source Type: $typeByte") - return Pair(mac, type) - } - - fun parseConnectedDevicesResponse(data: ByteArray): List { - Log.d( - TAG, - "Parsing Connected Devices Response: ${data.joinToString(" ") { "%02X".format(it) }}" - ) - if (data.size < 8) { - throw IllegalArgumentException("Data array too short to parse Connected Devices Response") - } - if (data[4] != Opcodes.CONNECTED_DEVICES) { - throw IllegalArgumentException("Data array does not start with CONNECTED_DEVICES opcode") - } - val deviceCount = data[8].toInt() - val devices = mutableListOf() - - var offset = 9 - for (i in 0 until deviceCount) { - if (offset + 8 > data.size) { - Log.w( - TAG, - "Data array too short to parse all connected devices, returning what we have" - ) - break + return ControlCommand(identifier, value) } - val macBytes = data.sliceArray(offset until offset + 6) - val mac = macBytes.joinToString(":") { "%02X".format(it) } - val info1 = data[offset + 6] - val info2 = data[offset + 7] - val existingDevice = devices.find { it.mac == mac } - devices.add(ConnectedDevice(mac, info1, info2, existingDevice?.type)) - offset += 8 } - - return devices - } - - fun sendSomePacketIDontKnowWhatItIs() { - // 2900 00ff ffff ffff ffff -- enables setting EQ - sendDataPacket( - byteArrayOf( - 0x29, 0x00, - 0x00, 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), - ) - ) - } - - fun disconnected() { - Log.d(TAG, "Disconnected, clearing state") - controlCommandStatusList.clear() - controlCommandListeners.clear() - owns = false - oldConnectedDevices = listOf() - connectedDevices = listOf() - audioSource = null - } - - fun parseInformationPacket(packet: ByteArray): AirPodsInformation { - val data = packet.sliceArray(6 until packet.size) - - var index = 0 - while (index < data.size && data[index] != 0x00.toByte()) index++ - - val strings = mutableListOf() - while (index < data.size) { - // skip 0x00 bytes - while (index < data.size && data[index] == 0x00.toByte()) index++ - if (index >= data.size) break - val start = index - // find next 0x00 byte - while (index < data.size && data[index] != 0x00.toByte()) index++ - val str = data.sliceArray(start until index).decodeToString() - strings.add(str) - } - - strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string - - return AirPodsInformation( - name = strings.getOrNull(0) ?: "", - modelNumber = strings.getOrNull(1) ?: "", - manufacturer = strings.getOrNull(2) ?: "", - serialNumber = strings.getOrNull(3) ?: "", - version1 = strings.getOrNull(4) ?: "", - version2 = strings.getOrNull(5) ?: "", - hardwareRevision = strings.getOrNull(6) ?: "", - updaterIdentifier = strings.getOrNull(7) ?: "", - leftSerialNumber = strings.getOrNull(8) ?: "", - rightSerialNumber = strings.getOrNull(9) ?: "", - version3 = strings.getOrNull(10) ?: "", - ) - } - - fun sendCustomEqPacket(customEq: CustomEq): Boolean { - return sendDataPacket(customEq.toPacket()) - } - - fun parseCustomEqPacket(packet: ByteArray): CustomEq { - val data = packet.sliceArray(6 until packet.size) - - if (data.size < 7) { - Log.e(TAG, "custom EQ packet length less than 7, returning default") - return CustomEq(1, 50, 50, 50) - } - - val lengthLow = data[0].toInt() and 0xFF - val lengthHigh = data[1].toInt() and 0xFF - - val length = (lengthHigh shl 8) or lengthLow - - if (length != 5) { - Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally") - } - - val state = data[3].toInt() - val low = data[4].toInt() - val mid = data[5].toInt() - val high = data[6].toInt() - - return CustomEq( - state, - low, - mid, - high - ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt index 753c4d3d7..dd82c5ffb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt @@ -6,19 +6,12 @@ it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . */ package me.kavishdevar.librepods.bluetooth import android.util.Log +import me.kavishdevar.librepods.wear.bluetooth.AirPodsProtocolTransport import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit @@ -34,23 +27,22 @@ enum class ATTHandles(val value: Int) { enum class ATTCCCDHandles(val value: Int) { TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1), - // LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work HEARING_AID(ATTHandles.HEARING_AID.value + 1) } -class ATTManagerv2 { +/** + * ATT protocol implementation with no dependency on global Bluetooth state. + * The transport is supplied by the Wear connection session. + */ +class ATTManager(private val transport: AirPodsProtocolTransport) { val characteristicList = mutableMapOf() - private val responseQueues = ConcurrentHashMap>() - private val readerRunning = AtomicBoolean(false) private var readerThread: Thread? = null - private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null fun startReader() { if (readerRunning.getAndSet(true)) return - readerThread = Thread { try { runReaderLoop() @@ -58,10 +50,8 @@ class ATTManagerv2 { Log.e(TAG, "reader thread crashed: ${t.message}", t) } finally { readerRunning.set(false) - Log.d(TAG, "reader thread stopped") } }.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() } - Log.d(TAG, "reader started") } fun stopReader() { @@ -79,35 +69,25 @@ class ATTManagerv2 { } fun getCharacteristic(handle: ATTHandles): ByteArray? { - val storedValue = characteristicList[handle] - return if (storedValue?.isNotEmpty() != true) { - readCharacteristic(handle) - } else storedValue + val stored = characteristicList[handle] + return if (stored?.isNotEmpty() != true) readCharacteristic(handle) else stored } fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? { - val socket = BluetoothConnectionManager.attSocket ?: return null - try { - val output = socket.outputStream + return try { + val output = transport.attOutput val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) synchronized(output) { output.write(pdu) output.flush() } - Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x0B, timeoutMillis) ?: run { - Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}") - return null - } - - Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}") - val value = resp.copyOfRange(1, resp.size) + val response = waitForResponse(0x0B, timeoutMillis) ?: return null + val value = response.copyOfRange(1, response.size) characteristicList[handle] = value - return value - } catch (e: Exception) { - Log.e(TAG, "error reading characteristic: ${e.message}") - return null + value + } catch (error: Exception) { + Log.e(TAG, "error reading characteristic: ${error.message}", error) + null } } @@ -117,95 +97,56 @@ class ATTManagerv2 { } fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) { - val socket = BluetoothConnectionManager.attSocket ?: return try { - val output = socket.outputStream - val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE + val output = transport.attOutput + val pdu = byteArrayOf(0x12, handle, 0x00) + data synchronized(output) { output.write(pdu) output.flush() } - Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x13, timeoutMillis) ?: run { - Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}") - return - } - - Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}") - } catch (e: Exception) { - Log.e(TAG, "error writing characteristic: ${e.message}") + waitForResponse(0x13, timeoutMillis) + } catch (error: Exception) { + Log.e(TAG, "error writing characteristic: ${error.message}", error) } } fun disconnected() { characteristicList.clear() stopReader() - val socket = BluetoothConnectionManager.attSocket?: return - try { - socket.close() - } catch (e: Exception) { - Log.w(TAG, "error closing socket: ${e.message}") - } + responseQueues.clear() Log.d(TAG, "ATT disconnected") } private fun runReaderLoop() { - val socket = BluetoothConnectionManager.attSocket ?: run { - Log.w(TAG, "ATT socket not available. stopping reader") - readerRunning.set(false) - return - } - - val input = socket.inputStream + val input = transport.attInput val buffer = ByteArray(512) - while (readerRunning.get()) { try { val len = input.read(buffer) - if (len == -1) { - Log.w(TAG, "ATT input stream ended") - break - } + if (len == -1) break + if (len == 0) continue val data = buffer.copyOfRange(0, len) - if (data.isEmpty()) continue - val opcode = data[0] - Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}") - - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - queue.offer(data) - - if (opcode == 0x1B.toByte()) { - if (data.size >= 3) { - val handle = data[1] - val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0) - Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}") - try { - onNotificationReceived?.invoke(handle, value) - } catch (t: Throwable) { - Log.e(TAG, "onNotificationReceived threw: ${t.message}", t) - } - } else { - Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}") - } + responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }.offer(data) + + if (opcode == 0x1B.toByte() && data.size >= 3) { + val handle = data[1] + val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0) + onNotificationReceived?.invoke(handle, value) } - } catch (e: Exception) { - Log.e(TAG, "error in reader loop: ${e.message}", e) + } catch (error: Exception) { + if (readerRunning.get()) Log.e(TAG, "error in reader loop", error) break } } - readerRunning.set(false) } - private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? { - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - return try { - queue.poll(timeoutMillis, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - e.printStackTrace() - null - } + private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? = try { + responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } + .poll(timeoutMillis, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + null } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt deleted file mode 100644 index 95d5d655f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.bluetooth - -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothSocket -import android.os.ParcelUuid -import android.util.Log - -object BluetoothConnectionManager { - var aacpSocket: BluetoothSocket? = null - var attSocket: BluetoothSocket? = null -} - -fun createBluetoothSocket( - adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int -): BluetoothSocket { - val type = 3 // L2CAP - val constructorSpecs = listOf( - arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 - arrayOf(device, type, true, true, psm, uuid), - arrayOf(device, type, 1, true, true, psm, uuid), - arrayOf(type, 1, true, true, device, psm, uuid), - arrayOf(type, true, true, device, psm, uuid) - ) - - val constructors = BluetoothSocket::class.java.declaredConstructors - Log.d("createSocket", "BluetoothSocket has ${constructors.size} constructors:") - - constructors.forEachIndexed { index, constructor -> - val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } - Log.d("createSocket", "Constructor $index: ($params)") - } - - var lastException: Exception? = null - var attemptedConstructors = 0 - - for ((index, params) in constructorSpecs.withIndex()) { - try { - Log.d("createSocket", "Trying constructor signature #${index + 1}") - attemptedConstructors++ - - val paramTypes = - params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() - val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes) - constructor.isAccessible = true - return constructor.newInstance(*params) as BluetoothSocket - - } catch (e: Exception) { - Log.e("createSocket", "Constructor signature #${index + 1} failed: ${e.message}") - lastException = e - } - } - - val errorMessage = - "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" - Log.e("createSocket", errorMessage) - throw lastException ?: IllegalStateException(errorMessage) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt index 9d83f05f5..fda6a7baf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt @@ -6,14 +6,6 @@ it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . */ package me.kavishdevar.librepods.data @@ -25,13 +17,14 @@ open class AirPodsBase( val name: String, val displayName: String = "AirPods", val manufacturer: String = "Apple Inc.", - val budCaseRes: Int, - val budsRes: Int, - val leftBudsRes: Int, - val rightBudsRes: Int, - val caseRes: Int, + val budCaseRes: Int = R.drawable.airpods, + val budsRes: Int = R.drawable.airpods, + val leftBudsRes: Int = R.drawable.airpods, + val rightBudsRes: Int = R.drawable.airpods, + val caseRes: Int = R.drawable.airpods, val capabilities: Set ) + enum class Capability { LISTENING_MODE, CONVERSATION_AWARENESS, @@ -47,203 +40,40 @@ enum class Capability { HRM } -class AirPods: AirPodsBase( - modelNumber = listOf("A1523", "A1722"), - name = "AirPods 1", - // budCaseRes = R.drawable.airpods_1 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_1_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_1_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_1_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_1_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = emptySet() +class AirPods : AirPodsBase(listOf("A1523", "A1722"), "AirPods 1", capabilities = emptySet()) +class AirPods2 : AirPodsBase(listOf("A2032", "A2031"), "AirPods 2", capabilities = emptySet()) +class AirPods3 : AirPodsBase(listOf("A2565", "A2564"), "AirPods 3", capabilities = setOf(Capability.HEAD_GESTURES)) +class AirPods4 : AirPodsBase( + listOf("A3053", "A3050", "A3054"), "AirPods 4", + capabilities = setOf(Capability.HEAD_GESTURES, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME) ) - -class AirPods2: AirPodsBase( - modelNumber = listOf("A2032", "A2031"), - name = "AirPods 2", - // budCaseRes = R.drawable.airpods_2 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_2_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_2_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_2_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_2_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = emptySet() +class AirPods4ANC : AirPodsBase( + listOf("A3056", "A3055", "A3057"), "AirPods 4 (ANC)", + capabilities = setOf(Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, + Capability.ADAPTIVE_AUDIO, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME, Capability.STEM_CONFIG) ) - -class AirPods3: AirPodsBase( - modelNumber = listOf("A2565", "A2564"), - name = "AirPods 3", - // budCaseRes = R.drawable.airpods_3 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_3_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_3_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_3_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_3_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.HEAD_GESTURES - ) +class AirPodsPro1 : AirPodsBase( + listOf("A2084", "A2083"), "AirPods Pro 1", "AirPods Pro", + capabilities = setOf(Capability.LISTENING_MODE) ) - -class AirPods4: AirPodsBase( - modelNumber = listOf("A3053", "A3050", "A3054"), - name = "AirPods 4", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_4_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_4_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_4_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.HEAD_GESTURES, - Capability.SLEEP_DETECTION, - Capability.ADAPTIVE_VOLUME - ) +class AirPodsPro2Lightning : AirPodsBase( + listOf("A2931", "A2699", "A2698"), "AirPods Pro 2 with Magsafe Charging Case (Lightning)", "AirPods Pro", + capabilities = setOf(Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.STEM_CONFIG, + Capability.LOUD_SOUND_REDUCTION, Capability.SLEEP_DETECTION, Capability.HEARING_AID, + Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, Capability.HEAD_GESTURES) ) - -class AirPods4ANC: AirPodsBase( - modelNumber = listOf("A3056", "A3055", "A3057"), - name = "AirPods 4 (ANC)", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_4_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_4_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_4_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.HEAD_GESTURES, - Capability.ADAPTIVE_AUDIO, - Capability.SLEEP_DETECTION, - Capability.ADAPTIVE_VOLUME, - Capability.STEM_CONFIG - ) +class AirPodsPro2USBC : AirPodsBase( + listOf("A3047", "A3048", "A3049"), "AirPods Pro 2 with Magsafe Charging Case (USB-C)", "AirPods Pro", + capabilities = setOf(Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.STEM_CONFIG, + Capability.LOUD_SOUND_REDUCTION, Capability.SLEEP_DETECTION, Capability.HEARING_AID, + Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, Capability.HEAD_GESTURES) ) - -class AirPodsPro1: AirPodsBase( - modelNumber = listOf("A2084", "A2083"), - name = "AirPods Pro 1", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_1 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_1_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_pro_1_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_pro_1_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_pro_1_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.LISTENING_MODE - ) -) - -class AirPodsPro2Lightning: AirPodsBase( - modelNumber = listOf("A2931", "A2699", "A2698"), - name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_2 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_2_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_pro_2_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_pro_2_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_pro_2_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES - ) -) - -class AirPodsPro2USBC: AirPodsBase( - modelNumber = listOf("A3047", "A3048", "A3049"), - name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_2 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_2_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_pro_2_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_pro_2_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_pro_2_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES - ) -) - -class AirPodsPro3: AirPodsBase( - modelNumber = listOf("A3063", "A3064", "A3065"), - name = "AirPods Pro 3", - displayName = "AirPods Pro", - // budCaseRes = R.drawable.airpods_pro_3 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_pro_3_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_pro_3_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_pro_3_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_pro_3_case - caseRes = R.drawable.airpods_pro_2_case, - capabilities = setOf( - Capability.LISTENING_MODE, - Capability.CONVERSATION_AWARENESS, - Capability.HEAD_GESTURES, - Capability.STEM_CONFIG, - Capability.LOUD_SOUND_REDUCTION, - Capability.PPE, - Capability.SLEEP_DETECTION, - Capability.HEARING_AID, - Capability.ADAPTIVE_AUDIO, - Capability.ADAPTIVE_VOLUME, - Capability.SWIPE_FOR_VOLUME, - Capability.HRM - ) +class AirPodsPro3 : AirPodsBase( + listOf("A3063", "A3064", "A3065"), "AirPods Pro 3", "AirPods Pro", + capabilities = setOf(Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, + Capability.STEM_CONFIG, Capability.LOUD_SOUND_REDUCTION, Capability.PPE, Capability.SLEEP_DETECTION, + Capability.HEARING_AID, Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, + Capability.HRM) ) data class AirPodsInstance( @@ -260,18 +90,10 @@ data class AirPodsInstance( object AirPodsModels { val models: List = listOf( - AirPods(), - AirPods2(), - AirPods3(), - AirPods4(), - AirPods4ANC(), - AirPodsPro1(), - AirPodsPro2Lightning(), - AirPodsPro2USBC(), - AirPodsPro3() + AirPods(), AirPods2(), AirPods3(), AirPods4(), AirPods4ANC(), + AirPodsPro1(), AirPodsPro2Lightning(), AirPodsPro2USBC(), AirPodsPro3() ) - fun getModelByModelNumber(modelNumber: String): AirPodsBase? { - return models.find { modelNumber in it.modelNumber } - } + fun getModelByModelNumber(modelNumber: String): AirPodsBase? = + models.find { modelNumber in it.modelNumber } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt index fe0232f3a..07f088130 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt @@ -18,11 +18,7 @@ package me.kavishdevar.librepods.data -import android.os.Parcelable import android.util.Log -import kotlinx.parcelize.Parcelize - -// TODO: Remove everything but Battery-related stuff enum class Enums(val value: ByteArray) { NOISE_CANCELLATION(byteArrayOf(0x0d)), @@ -45,30 +41,26 @@ object BatteryStatus { const val OPTIMIZED_CHARGING = 5 } -@Parcelize -data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable { - fun getComponentName(): String? { - return when (component) { - BatteryComponent.LEFT -> "LEFT" - BatteryComponent.RIGHT -> "RIGHT" - BatteryComponent.CASE -> "CASE" - else -> null - } +/** Plain Wear OS data model; it does not need Android Parcelable overhead. */ +data class Battery(val component: Int, val level: Int, val status: Int) { + fun getComponentName(): String? = when (component) { + BatteryComponent.LEFT -> "LEFT" + BatteryComponent.RIGHT -> "RIGHT" + BatteryComponent.CASE -> "CASE" + else -> null } - fun getStatusName(): String? { - return when (status) { - BatteryStatus.CHARGING -> "CHARGING" - BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" - BatteryStatus.DISCONNECTED -> "DISCONNECTED" - BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING" - else -> null - } + fun getStatusName(): String? = when (status) { + BatteryStatus.CHARGING -> "CHARGING" + BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" + BatteryStatus.DISCONNECTED -> "DISCONNECTED" + BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING" + else -> null } } enum class NoiseControlMode { - OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE + OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE } class AirPodsNotifications { @@ -90,7 +82,6 @@ class AirPodsNotifications { class EarDetection { private val notificationBit = 6.toByte() private val notificationPrefix = Enums.PREFIX.value + notificationBit - var status: List = listOf(0x01, 0x01) fun setStatus(data: ByteArray) { @@ -98,78 +89,46 @@ class AirPodsNotifications { } fun isEarDetectionData(data: ByteArray): Boolean { - if (data.size != 8) { - return false - } - val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) + if (data.size != 8) return false + return data.startsWith(notificationPrefix) } } class ANC { private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value - var status: Int = 1 private set fun isANCData(data: ByteArray): Boolean { - if (data.size != 11) { - return false - } - val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) + if (data.size != 11) return false + return data.startsWith(notificationPrefix) } fun setStatus(data: ByteArray) { when (data.size) { - // if the whole packet is given - 11 -> { - status = data[7].toInt() - } - // if only the data is given - 1 -> { - status = data[0].toInt() - } - // if the value of control command is given - 4 -> { - status = data[0].toInt() - } - else -> { - Log.d("ANC", "Invalid ANC data size: ${data.size}") - } + 11 -> status = data[7].toInt() + 1, 4 -> status = data[0].toInt() + else -> Log.d("ANC", "Invalid ANC data size: ${data.size}") } } - val name: String = - when (status) { + val name: String + get() = when (status) { 1 -> "OFF" 2 -> "ON" 3 -> "TRANSPARENCY" 4 -> "ADAPTIVE" else -> "UNKNOWN" } - } class BatteryNotification { - private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED) - private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED) - private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) + private var first = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED) + private var second = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED) + private var case = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) fun isBatteryData(data: ByteArray): Boolean { - if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) { - Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.") - } else { - return false - } - if (data.size != 22) { - Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.") - return false - } - Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString()) - return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400") + return data.size == 22 && data.startsWith(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x04, 0x00)) } fun setBatteryDirect( @@ -178,7 +137,7 @@ class AirPodsNotifications { rightLevel: Int, rightCharging: Boolean, caseLevel: Int, - caseCharging: Boolean + caseCharging: Boolean, ) { first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) @@ -186,34 +145,10 @@ class AirPodsNotifications { } fun setBattery(data: ByteArray) { - if (data.size != 22) { - return - } -// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(first.component, first.level, data[10].toInt()) -// } else { -// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) -// } -// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(second.component, second.level, data[15].toInt()) -// } else { -// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) -// } -// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { -// Battery(case.component, case.level, data[20].toInt()) -// } else { -// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) -// } -// sometimes it shows battery as -1%, just skip all that and set it normally - first = Battery( - data[7].toInt(), data[9].toInt(), data[10].toInt() - ) - second = Battery( - data[12].toInt(), data[14].toInt(), data[15].toInt() - ) - case = Battery( - data[17].toInt(), data[19].toInt(), data[20].toInt() - ) + if (data.size != 22) return + first = Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) + second = Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) + case = Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) } fun getBattery(): List { @@ -224,19 +159,12 @@ class AirPodsNotifications { } class ConversationalAwarenessNotification { - @Suppress("PrivatePropertyName") - private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value - + private val notificationPrefix = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value var status: Byte = 0 private set fun isConversationalAwarenessData(data: ByteArray): Boolean { - if (data.size != 10) { - return false - } - val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) } - val dataHex = data.joinToString("") { "%02x".format(it) } - return dataHex.startsWith(prefixHex) + return data.size == 10 && data.startsWith(notificationPrefix) } fun setData(data: ByteArray) { @@ -245,12 +173,17 @@ class AirPodsNotifications { } } +private fun ByteArray.startsWith(prefix: ByteArray): Boolean { + if (size < prefix.size) return false + return prefix.indices.all { this[it] == prefix[it] } +} + fun isHeadTrackingData(data: ByteArray): Boolean { if (data.size <= 60) return false val prefixPattern = byteArrayOf( 0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, - 0x10, 0x00 + 0x10, 0x00, ) for (i in prefixPattern.indices) { @@ -258,8 +191,5 @@ fun isHeadTrackingData(data: ByteArray): Boolean { } if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false - - if (data[11] != 0x00.toByte()) return false - - return true + return data[11] == 0x00.toByte() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt deleted file mode 100644 index 1977b0436..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt +++ /dev/null @@ -1,8 +0,0 @@ -package me.kavishdevar.librepods.data - -interface XposedRemotePref { - fun isAvailable(): Boolean - - fun getBoolean(key: String, def: Boolean): Boolean - fun putBoolean(key: String, value: Boolean) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt deleted file mode 100644 index 112e7527c..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt +++ /dev/null @@ -1,21 +0,0 @@ -package me.kavishdevar.librepods.data - -import androidx.core.content.edit -import me.kavishdevar.librepods.utils.XposedServiceHolder - -class XposedRemotePrefImpl: XposedRemotePref { - override fun isAvailable(): Boolean { - return XposedServiceHolder.service != null - } - - override fun getBoolean(key: String, def: Boolean): Boolean { - val s = XposedServiceHolder.service ?: return def - return s.getRemotePreferences("me.kavishdevar.librepods").getBoolean(key, def) - } - - override fun putBoolean(key: String, value: Boolean) { - val s = XposedServiceHolder.service ?: return - s.getRemotePreferences("me.kavishdevar.librepods") - .edit { putBoolean(key, value) } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt deleted file mode 100644 index 9f18e8ca0..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package me.kavishdevar.librepods.data - -object XposedRemotePrefProvider { - fun create(): XposedRemotePref = XposedRemotePrefImpl() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt deleted file mode 100644 index f7d82ebf7..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt +++ /dev/null @@ -1,35 +0,0 @@ -package me.kavishdevar.librepods.data.updates - -import androidx.compose.runtime.Composable -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial -import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple -import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem - -val update1_0_0 = listOf( - UpdateItem( - titleRes = R.string.material3e, - descriptionRes = R.string.update_m3e_description, - demoComposeable = @Composable { - AirPodsSettingsScreenPreviewMaterial() - } - ), - UpdateItem( - titleRes = R.string.equalizer, - descriptionRes = R.string.update_equalizer_description, - demoComposeable = @Composable { - when (LocalDesignSystem.current) { - DesignSystem.Apple -> { - EqualizerScreenPreviewApple() - } - DesignSystem.Material -> { - EqualizerScreenPreviewMaterial() - } - } - } - ), - ) - -val updates = update1_0_0 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt deleted file mode 100644 index f4d6bb4aa..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt +++ /dev/null @@ -1,528 +0,0 @@ -package me.kavishdevar.librepods.presentation - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp - -object MaterialIcons { - val notifications: ImageVector - get() { - if (_notifications != null) { - return _notifications!! - } - _notifications = - ImageVector.Builder( - name = "notifications", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(4f, 19f) - verticalLineTo(17f) - horizontalLineTo(6f) - verticalLineTo(10f) - quadTo(6f, 7.93f, 7.25f, 6.31f) - reflectiveQuadTo(10.5f, 4.2f) - verticalLineTo(3.5f) - quadToRelative(0f, -0.63f, 0.44f, -1.06f) - reflectiveQuadTo(12f, 2f) - reflectiveQuadToRelative(1.06f, 0.44f) - reflectiveQuadTo(13.5f, 3.5f) - verticalLineTo(4.2f) - quadToRelative(2f, 0.5f, 3.25f, 2.11f) - reflectiveQuadTo(18f, 10f) - verticalLineToRelative(7f) - horizontalLineToRelative(2f) - verticalLineToRelative(2f) - horizontalLineTo(4f) - close() - moveToRelative(8f, -7.5f) - close() - moveTo(12f, 22f) - quadToRelative(-0.82f, 0f, -1.41f, -0.59f) - reflectiveQuadTo(10f, 20f) - horizontalLineToRelative(4f) - quadToRelative(0f, 0.82f, -0.59f, 1.41f) - reflectiveQuadTo(12f, 22f) - close() - moveTo(8f, 17f) - horizontalLineToRelative(8f) - verticalLineTo(10f) - quadTo(16f, 8.35f, 14.83f, 7.18f) - reflectiveQuadTo(12f, 6f) - reflectiveQuadTo(9.18f, 7.18f) - reflectiveQuadTo(8f, 10f) - verticalLineToRelative(7f) - close() - } - } - .build() - return _notifications!! - } - - private var _notifications: ImageVector? = null - - val headset_off: ImageVector - get() { - if (_headset_off != null) { - return _headset_off!! - } - _headset_off = - ImageVector.Builder( - name = "headset_off", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(21f, 18.15f) - lineToRelative(-2f, -2f) - verticalLineTo(14f) - horizontalLineTo(16.85f) - lineToRelative(-2f, -2f) - horizontalLineTo(19f) - verticalLineTo(11f) - quadTo(19f, 8.05f, 16.95f, 6.02f) - reflectiveQuadTo(12f, 4f) - quadTo(10.9f, 4f, 9.91f, 4.31f) - reflectiveQuadTo(8.1f, 5.2f) - lineTo(6.65f, 3.8f) - quadTo(7.78f, 2.92f, 9.14f, 2.46f) - reflectiveQuadTo(12f, 2f) - quadToRelative(1.85f, 0f, 3.49f, 0.7f) - reflectiveQuadToRelative(2.86f, 1.93f) - reflectiveQuadToRelative(1.94f, 2.86f) - reflectiveQuadTo(21f, 11f) - verticalLineToRelative(7.15f) - close() - moveTo(12f, 23f) - verticalLineTo(21f) - horizontalLineToRelative(6.18f) - lineToRelative(-1f, -1f) - horizontalLineTo(15f) - verticalLineTo(17.83f) - lineTo(5.53f, 8.35f) - quadTo(5.3f, 8.95f, 5.15f, 9.64f) - reflectiveQuadTo(5f, 11f) - verticalLineToRelative(1f) - horizontalLineTo(9f) - verticalLineToRelative(8f) - horizontalLineTo(5f) - quadTo(4.18f, 20f, 3.59f, 19.41f) - reflectiveQuadTo(3f, 18f) - verticalLineTo(11f) - quadTo(3f, 9.88f, 3.26f, 8.82f) - reflectiveQuadToRelative(0.76f, -2f) - lineTo(0.68f, 3.5f) - lineTo(2.1f, 2.1f) - lineTo(21.88f, 21.9f) - verticalLineTo(23f) - horizontalLineTo(12f) - close() - moveTo(5f, 18f) - horizontalLineTo(7f) - verticalLineTo(14f) - horizontalLineTo(5f) - verticalLineToRelative(4f) - close() - moveTo(5f, 14f) - horizontalLineTo(7f) - horizontalLineTo(5f) - close() - moveToRelative(11.85f, 0f) - horizontalLineTo(19f) - horizontalLineTo(16.85f) - close() - } - } - .build() - return _headset_off!! - } - - private var _headset_off: ImageVector? = null - - val pause: ImageVector - get() { - if (_pause != null) { - return _pause!! - } - _pause = - ImageVector.Builder( - name = "pause", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(13f, 19f) - verticalLineTo(5f) - horizontalLineToRelative(6f) - verticalLineTo(19f) - horizontalLineTo(13f) - close() - moveTo(5f, 19f) - verticalLineTo(5f) - horizontalLineToRelative(6f) - verticalLineTo(19f) - horizontalLineTo(5f) - close() - moveTo(15f, 17f) - horizontalLineToRelative(2f) - verticalLineTo(7f) - horizontalLineTo(15f) - verticalLineTo(17f) - close() - moveTo(7f, 17f) - horizontalLineTo(9f) - verticalLineTo(7f) - horizontalLineTo(7f) - verticalLineTo(17f) - close() - moveTo(7f, 7f) - verticalLineTo(17f) - verticalLineTo(7f) - close() - moveToRelative(8f, 0f) - verticalLineTo(17f) - verticalLineTo(7f) - close() - } - } - .build() - return _pause!! - } - - private var _pause: ImageVector? = null - - val bluetooth: ImageVector - get() { - if (_bluetooth != null) { - return _bluetooth!! - } - _bluetooth = - ImageVector.Builder( - name = "bluetooth", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(11f, 22f) - verticalLineTo(14.4f) - lineTo(6.4f, 19f) - lineTo(5f, 17.6f) - lineTo(10.6f, 12f) - lineTo(5f, 6.4f) - lineTo(6.4f, 5f) - lineTo(11f, 9.6f) - verticalLineTo(2f) - horizontalLineToRelative(1f) - lineToRelative(5.7f, 5.7f) - lineTo(13.4f, 12f) - lineToRelative(4.3f, 4.3f) - lineTo(12f, 22f) - horizontalLineTo(11f) - close() - moveTo(13f, 9.6f) - lineTo(14.9f, 7.7f) - lineTo(13f, 5.85f) - verticalLineTo(9.6f) - close() - moveToRelative(0f, 8.55f) - lineTo(14.9f, 16.3f) - lineTo(13f, 14.4f) - verticalLineToRelative(3.75f) - close() - } - } - .build() - return _bluetooth!! - } - - private var _bluetooth: ImageVector? = null - - val bluetooth_searching: ImageVector - get() { - if (_bluetooth_searching != null) { - return _bluetooth_searching!! - } - _bluetooth_searching = - ImageVector.Builder( - name = "bluetooth_searching", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(9f, 22f) - verticalLineTo(14.4f) - lineTo(4.4f, 19f) - lineTo(3f, 17.6f) - lineTo(8.6f, 12f) - lineTo(3f, 6.4f) - lineTo(4.4f, 5f) - lineTo(9f, 9.6f) - verticalLineTo(2f) - horizontalLineToRelative(1f) - lineToRelative(5.7f, 5.7f) - lineTo(11.4f, 12f) - lineToRelative(4.3f, 4.3f) - lineTo(10f, 22f) - horizontalLineTo(9f) - close() - moveTo(11f, 9.6f) - lineTo(12.9f, 7.7f) - lineTo(11f, 5.85f) - verticalLineTo(9.6f) - close() - moveToRelative(0f, 8.55f) - lineTo(12.9f, 16.3f) - lineTo(11f, 14.4f) - verticalLineToRelative(3.75f) - close() - moveToRelative(5.55f, -3.8f) - lineTo(14.25f, 12f) - lineToRelative(2.3f, -2.3f) - quadToRelative(0.23f, 0.55f, 0.36f, 1.13f) - reflectiveQuadTo(17.05f, 12f) - reflectiveQuadToRelative(-0.14f, 1.19f) - quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f) - close() - moveTo(19.5f, 17.2f) - lineTo(18.25f, 16f) - quadToRelative(0.5f, -0.93f, 0.78f, -1.94f) - reflectiveQuadTo(19.3f, 12f) - reflectiveQuadTo(19.03f, 9.94f) - quadTo(18.75f, 8.92f, 18.25f, 8f) - lineTo(19.5f, 6.75f) - quadToRelative(0.73f, 1.2f, 1.11f, 2.52f) - reflectiveQuadTo(21f, 12f) - reflectiveQuadToRelative(-0.39f, 2.71f) - quadTo(20.23f, 16.02f, 19.5f, 17.2f) - close() - } - } - .build() - return _bluetooth_searching!! - } - - private var _bluetooth_searching: ImageVector? = null - - val call: ImageVector - get() { - if (_call != null) { - return _call!! - } - _call = - ImageVector.Builder( - name = "call", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.Companion.NonZero, - ) { - moveTo(19.95f, 21f) - quadToRelative(-3.13f, 0f, -6.18f, -1.36f) - reflectiveQuadTo(8.23f, 15.78f) - quadTo(5.73f, 13.27f, 4.36f, 10.23f) - reflectiveQuadTo(3f, 4.05f) - quadTo(3f, 3.6f, 3.3f, 3.3f) - reflectiveQuadTo(4.05f, 3f) - horizontalLineTo(8.1f) - quadTo(8.45f, 3f, 8.73f, 3.24f) - reflectiveQuadTo(9.05f, 3.8f) - lineTo(9.7f, 7.3f) - quadTo(9.75f, 7.7f, 9.68f, 7.97f) - reflectiveQuadTo(9.4f, 8.45f) - lineTo(6.98f, 10.9f) - quadToRelative(0.5f, 0.93f, 1.19f, 1.79f) - reflectiveQuadToRelative(1.51f, 1.66f) - quadToRelative(0.78f, 0.78f, 1.63f, 1.44f) - reflectiveQuadTo(13.1f, 17f) - lineToRelative(2.35f, -2.35f) - quadToRelative(0.22f, -0.23f, 0.59f, -0.34f) - reflectiveQuadToRelative(0.71f, -0.06f) - lineToRelative(3.45f, 0.7f) - quadToRelative(0.35f, 0.1f, 0.57f, 0.36f) - reflectiveQuadTo(21f, 15.9f) - verticalLineToRelative(4.05f) - quadToRelative(0f, 0.45f, -0.3f, 0.75f) - reflectiveQuadTo(19.95f, 21f) - close() - moveTo(6.03f, 9f) - lineTo(7.68f, 7.35f) - lineTo(7.25f, 5f) - horizontalLineTo(5.03f) - quadTo(5.15f, 6.02f, 5.38f, 7.02f) - reflectiveQuadTo(6.03f, 9f) - close() - moveToRelative(8.95f, 8.95f) - quadToRelative(0.97f, 0.43f, 1.99f, 0.68f) - reflectiveQuadTo(19f, 18.95f) - verticalLineToRelative(-2.2f) - lineTo(16.65f, 16.27f) - lineToRelative(-1.68f, 1.68f) - close() - moveTo(6.03f, 9f) - close() - moveToRelative(8.95f, 8.95f) - close() - } - } - .build() - return _call!! - } - - private var _call: ImageVector? = null - - val stack: ImageVector - get() { - if (_stack != null) { - return _stack!! - } - _stack = - ImageVector.Builder( - name = "stack", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - .apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1f, - stroke = null, - strokeAlpha = 1f, - strokeLineWidth = 1f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Bevel, - strokeLineMiter = 1f, - pathFillType = PathFillType.Companion.NonZero, - ) { - moveTo(6f, 14f) - verticalLineToRelative(2f) - horizontalLineTo(4f) - quadTo(3.18f, 16f, 2.59f, 15.41f) - reflectiveQuadTo(2f, 14f) - verticalLineTo(4f) - quadTo(2f, 3.17f, 2.59f, 2.59f) - reflectiveQuadTo(4f, 2f) - horizontalLineTo(14f) - quadToRelative(0.83f, 0f, 1.41f, 0.59f) - reflectiveQuadTo(16f, 4f) - verticalLineTo(6f) - horizontalLineTo(14f) - verticalLineTo(4f) - horizontalLineTo(4f) - verticalLineTo(14f) - horizontalLineTo(6f) - close() - moveToRelative(4f, 8f) - quadTo(9.18f, 22f, 8.59f, 21.41f) - reflectiveQuadTo(8f, 20f) - verticalLineTo(10f) - quadTo(8f, 9.17f, 8.59f, 8.59f) - reflectiveQuadTo(10f, 8f) - horizontalLineTo(20f) - quadToRelative(0.83f, 0f, 1.41f, 0.59f) - reflectiveQuadTo(22f, 10f) - verticalLineTo(20f) - quadToRelative(0f, 0.82f, -0.59f, 1.41f) - reflectiveQuadTo(20f, 22f) - horizontalLineTo(10f) - close() - moveToRelative(0f, -2f) - horizontalLineTo(20f) - verticalLineTo(10f) - horizontalLineTo(10f) - verticalLineTo(20f) - close() - moveToRelative(5f, -5f) - close() - } - } - .build() - return _stack!! - } - - private var _stack: ImageVector? = null -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt deleted file mode 100644 index 00b18672f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun AboutCard( - modelName: String, - actualModel: String, - serialNumbers: List, - version: String?, - navigateToVersion: () -> Unit -) { - val serialNumbers = when (LocalDesignSystem.current) { - DesignSystem.Apple -> listOf( - serialNumbers[0], - "􀀛 ${serialNumbers[1]}", - "􀀧 ${serialNumbers[2]}" - ) - - DesignSystem.Material -> listOf( - serialNumbers[0], - stringResource(R.string.left) + " " + serialNumbers[1], - stringResource(R.string.right) + " " + serialNumbers[2], - ) - } - - val serialNumber = remember { mutableIntStateOf(0) } - - StyledList (title = stringResource(R.string.about)) { - StyledListItem( - name = stringResource(R.string.model_name), - description = modelName - ) - - StyledListItem( - name = stringResource(R.string.model_number), - description = actualModel - ) - - StyledListItem ( - name = stringResource(R.string.serial_number), - description = serialNumbers[serialNumber.intValue], - onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size } - ) - - StyledListItem( - name = stringResource(R.string.version), - description = version, - onClick = navigateToVersion, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt deleted file mode 100644 index 992cf85d6..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R - -@Composable -fun AppInfoCard( - navigateToReleaseNotesScreen: (() -> Unit)? = null, -) { - StyledList(title = stringResource(R.string.about)) { - StyledListItem( - name = stringResource(R.string.version), - description = BuildConfig.VERSION_NAME, - onClick = navigateToReleaseNotesScreen - ) - - StyledListItem( - name = stringResource(R.string.version_code), - description = BuildConfig.VERSION_CODE.toString(), - ) - - StyledListItem( - name = stringResource(R.string.flavor), - description = BuildConfig.FLAVOR, - ) - - StyledListItem( - name = stringResource(R.string.build_type), - description = BuildConfig.BUILD_TYPE, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt deleted file mode 100644 index 396c72cf4..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun AudioSettings( - adaptiveVolumeCapability: Boolean, - conversationalAwarenessCapability: Boolean, - loudSoundReductionCapability: Boolean, - adaptiveAudioCapability: Boolean, - customEqCapability: Boolean, - - adaptiveVolumeChecked: Boolean, - onAdaptiveVolumeCheckedChange: (Boolean) -> Unit, - - conversationalAwarenessChecked: Boolean, - onConversationalAwarenessCheckedChange: (Boolean) -> Unit, - - loudSoundReductionChecked: Boolean, - onLoudSoundReductionCheckedChange: (Boolean) -> Unit, - - navigateToAdaptiveStrength: () -> Unit, - navigateToEqualizer: () -> Unit, - - vendorIdHook: Boolean, - isPremium: Boolean -) { - if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) { - StyledList(title = stringResource(R.string.audio)) { - if (adaptiveVolumeCapability) { - StyledToggle( - label = stringResource(R.string.personalized_volume), - description = stringResource(R.string.personalized_volume_description), - checked = adaptiveVolumeChecked, - onCheckedChange = onAdaptiveVolumeCheckedChange, - enabled = isPremium, - ) - } - - if (conversationalAwarenessCapability) { - StyledToggle( - label = stringResource(R.string.conversational_awareness), - description = stringResource(R.string.conversational_awareness_description), - checked = conversationalAwarenessChecked, - onCheckedChange = onConversationalAwarenessCheckedChange, - enabled = isPremium, - ) - } - - if (loudSoundReductionCapability && vendorIdHook) { - StyledToggle( - label = stringResource(R.string.loud_sound_reduction), - description = stringResource(R.string.loud_sound_reduction_description), - checked = loudSoundReductionChecked, - onCheckedChange = onLoudSoundReductionCheckedChange, - enabled = isPremium, - ) - } - - if (adaptiveAudioCapability) { - StyledListItem( - name = stringResource(R.string.adaptive_audio), - onClick = navigateToAdaptiveStrength, - ) - } - - if (customEqCapability) { - StyledListItem( - name = stringResource(R.string.equalizer), - onClick = navigateToEqualizer, - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt deleted file mode 100644 index 4c0d43518..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt +++ /dev/null @@ -1,212 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - - -import android.content.res.Configuration -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.BatteryStatus -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import kotlin.math.cos -import kotlin.math.min -import kotlin.math.sin -import kotlin.math.sqrt - -@Composable -fun BatteryIndicator( - batteryPercentage: Int, - status: Int, - prefix: String = "", - previousCharging: Boolean = false, -) { - val isDarkTheme = isSystemInDarkTheme() - val batteryTextColor = if (isDarkTheme) Color.White else Color.Black - val batteryFillColor = - if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759) - else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C) - - val initialScale = if (previousCharging) 1f else 0f - val scaleAnim = remember { Animatable(initialScale) } - val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING - val targetScale = if (charging) 1f else 0f - - LaunchedEffect(previousCharging, charging) { - scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250)) - } - - Column( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center - ) { - val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() } - val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() } - - val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8) - val optimizedLimit = 0.8f - val progress = batteryPercentage / 100f - - Canvas(modifier = Modifier.size(34.dp)) { - val startAngle = -90f - val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round) - val inset = strokeWidthPx / 2 - Rect( - left = inset, - top = inset, - right = size.width - inset, - bottom = size.height - inset - ) - val radius = size.minDimension / 2 - - if (status == BatteryStatus.OPTIMIZED_CHARGING) { - drawArc( - color = trackColor, - startAngle = startAngle, - sweepAngle = 360f * optimizedLimit, - useCenter = false, - style = stroke - ) - - val sweep = 360f * min(progress, optimizedLimit) - drawArc( - color = batteryFillColor, - startAngle = startAngle, - sweepAngle = sweep, - useCenter = false, - style = stroke - ) - - // ---- PILL MARKER AT 80% ---- - val angleDeg = startAngle + 360f * optimizedLimit - val angleRad = Math.toRadians(angleDeg.toDouble()) - - val arcRadius = radius - strokeWidthPx - - val outerX = center.x + arcRadius * cos(angleRad).toFloat() - val outerY = center.y + arcRadius * sin(angleRad).toFloat() - - val dirX = center.x - outerX - val dirY = center.y - outerY - val length = sqrt(dirX * dirX + dirY * dirY) - - val normX = dirX / length - val normY = dirY / length - - val startX = outerX - normX * strokeWidthPx / 2 - val startY = outerY - normY * strokeWidthPx / 2 - - val endX = center.x - normX * gapFromCenterPx - val endY = center.y - normY * gapFromCenterPx - - drawLine( - color = if (batteryPercentage >= 80) batteryFillColor else trackColor, - start = Offset(startX, startY), - end = Offset(endX, endY), - strokeWidth = strokeWidthPx, - cap = StrokeCap.Round - ) - } else { - drawArc( - color = trackColor, - startAngle = 0f, - sweepAngle = 360f, - useCenter = false, - style = stroke - ) - - drawArc( - color = batteryFillColor, - startAngle = startAngle, - sweepAngle = 360f * progress, - useCenter = false, - style = stroke - ) - } - } - - Text( - text = "\uDBC0\uDEE6", style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = batteryFillColor, - textAlign = TextAlign.Center - ), modifier = Modifier.scale(scaleAnim.value) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "$prefix $batteryPercentage%", - color = batteryTextColor, - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - textAlign = TextAlign.Center - ), - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun BatteryIndicatorPreview() { - LibrePodsTheme(m3eEnabled = false) { - BatteryIndicator( - batteryPercentage = 50, - status = BatteryStatus.OPTIMIZED_CHARGING, - prefix = "\uDBC6\uDCE5", - previousCharging = false - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt deleted file mode 100644 index a3f9dffa5..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.res.imageResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.Battery -import me.kavishdevar.librepods.data.BatteryComponent -import me.kavishdevar.librepods.data.BatteryStatus -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun BatteryView( - batteryList: List, - budsRes: Int, - caseRes: Int -) { - val left = batteryList.find { it.component == BatteryComponent.LEFT } - val right = batteryList.find { it.component == BatteryComponent.RIGHT } - val case = batteryList.find { it.component == BatteryComponent.CASE } - - val leftLevel = left?.level ?: 0 - val rightLevel = right?.level ?: 0 - val caseLevel = case?.level ?: 0 - - val singleDisplayed = remember { mutableStateOf(false) } - - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier.widthIn(max = 500.dp), - horizontalArrangement = Arrangement.Center - ) { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - bitmap = ImageBitmap.imageResource(budsRes), - contentDescription = stringResource(R.string.buds), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - - if ( - left?.status == right?.status && - (leftLevel - rightLevel) in -3..3 - ) { - BatteryIndicator( - leftLevel.coerceAtMost(rightLevel), - left?.status ?: BatteryStatus.NOT_CHARGING - ) - singleDisplayed.value = true - } else { - singleDisplayed.value = false - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) { - BatteryIndicator( - leftLevel, - left?.status ?: BatteryStatus.NOT_CHARGING, - "\uDBC6\uDCE5" - ) - } - - if (leftLevel > 0 && rightLevel > 0) { - Spacer(modifier = Modifier.width(16.dp)) - } - - if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) { - BatteryIndicator( - rightLevel, - right?.status ?: BatteryStatus.NOT_CHARGING, - "\uDBC6\uDCE8" - ) - } - } - } - } - - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - bitmap = ImageBitmap.imageResource(caseRes), - contentDescription = stringResource(R.string.case_alt), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - - if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { - BatteryIndicator( - caseLevel, - case?.status ?: BatteryStatus.NOT_CHARGING, - prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "" - ) - } - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun BatteryViewPreview() { - val fakeBattery = listOf( - Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING), - Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING), - Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING) - ) - - val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) - - Box( - modifier = Modifier - .background(bg) - .padding(16.dp) - ) { - BatteryView( - batteryList = fakeBattery, - budsRes = R.drawable.airpods_pro_2_buds, - caseRes = R.drawable.airpods_pro_2_case - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt deleted file mode 100644 index e21b7efb2..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.R -import kotlin.io.encoding.ExperimentalEncodingApi - -@ExperimentalHazeMaterialsApi -@Composable -fun CallControlSettings( - flipped: Boolean, - navigateToCallControlScreen: (action: String) -> Unit, -) { - val pressOnceText = stringResource(R.string.press_once) - val pressTwiceText = stringResource(R.string.press_twice) - - var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } - var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } - - val muteUnmuteText = stringResource(R.string.mute_unmute) - val hangUpText = stringResource(R.string.hang_up) - - StyledList(title = stringResource(R.string.call_controls)) { - StyledListItem( - name = stringResource(R.string.answer_call), - description = stringResource(R.string.press_once), - enabled = false - ) - - StyledListItem( - name = muteUnmuteText, - description = singlePressAction, - onClick = { navigateToCallControlScreen(muteUnmuteText) } , - ) - - StyledListItem( - name = hangUpText, - description = doublePressAction, - onClick = { navigateToCallControlScreen(hangUpText) } - ) - -// StyledListItem( -// name = pressOnceText, -// selected = doublePressAction == pressOnceText, -// onClick = { -// doublePressAction = pressOnceText -// singlePressAction = pressTwiceText -// -// onCallControlValueChanged(true) -// } -// ) -// -// StyledListItem( -// name = pressTwiceText, -// selected = doublePressAction == pressTwiceText, -// onClick = { -// doublePressAction = pressTwiceText -// singlePressAction = pressOnceText -// -// onCallControlValueChanged(false) -// } -// ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt deleted file mode 100644 index 3d7fc2873..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.DialogProperties -import com.kyant.backdrop.backdrops.LayerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.effects.vibrancy -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem - -@OptIn(ExperimentalMaterial3Api::class) -@ExperimentalHazeMaterialsApi -@Composable -fun ConfirmationDialog( - showDialog: MutableState, - title: String, - message: String, - confirmText: String = "Enable", - dismissText: String = "Cancel", - onConfirm: () -> Unit, - onDismiss: () -> Unit = { showDialog.value = false }, - backdrop: LayerBackdrop, -) { - AnimatedVisibility( - visible = showDialog.value, - enter = scaleIn(initialScale = 1.05f) + fadeIn(), - exit = scaleOut(targetScale = 1.05f) + fadeOut() - ) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - BasicAlertDialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = false - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMediumEmphasized - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium - ) - Row(modifier = Modifier.align(Alignment.End)) { - TextButton( - onClick = onDismiss - ) { - Text( - text = dismissText, - style = MaterialTheme.typography.labelMedium - ) - } - TextButton( - onClick = onConfirm - ) { - Text( - text = confirmText, - style = MaterialTheme.typography.labelMedium - ) - } - } - } - } - } - DesignSystem.Apple -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - val innerBackdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)) - .clickable(enabled = false, onClick = {}), - contentAlignment = Alignment.Center - ) { - val isDarkTheme = isSystemInDarkTheme() - Box( - modifier = Modifier - .requiredWidthIn(min = 200.dp, max = 360.dp) - .clip(RoundedCornerShape(48.dp)) - .drawBackdrop( - backdrop = backdrop, - exportedBackdrop = innerBackdrop, - shape = { RoundedCornerShape(48.dp) }, - effects = { - vibrancy() - blur(4f.dp.toPx()) - lens(12f.dp.toPx(), 48f.dp.toPx(), true) - }, - onDrawSurface = { - drawRect( - if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color( - 0xFFE0E0E0 - ).copy(alpha = 0.7f) - ) - }) - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(modifier = Modifier.height(24.dp)) - Text( - title, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - message, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(0.9f), - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - StyledButton( - onClick = onDismiss, - backdrop = innerBackdrop, - modifier = Modifier.weight(1f), - materialButtonStyle = MaterialButtonStyle.Outlined, - ) { - Text( - text = dismissText, - style = MaterialTheme.typography.bodyMedium - ) - } - StyledButton( - onClick = onConfirm, - backdrop = innerBackdrop, - modifier = Modifier.weight(1f), - materialButtonStyle = MaterialButtonStyle.Filled, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - text = confirmText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - } - } - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt deleted file mode 100644 index 72f369525..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun ConnectionSettings( - automaticEarDetectionEnabled: Boolean, - onAutomaticEarDetectionChanged: (Boolean) -> Unit, - automaticConnectionEnabled: Boolean, - onAutomaticConnectionChanged: (Boolean) -> Unit, -) { - StyledList { - StyledToggle( - label = stringResource(R.string.ear_detection), - checked = automaticEarDetectionEnabled, - onCheckedChange = onAutomaticEarDetectionChanged - ) - - StyledToggle( - label = stringResource(R.string.automatically_connect), - description = stringResource(R.string.automatically_connect_description), - checked = automaticConnectionEnabled, - onCheckedChange = onAutomaticConnectionChanged - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt deleted file mode 100644 index 5340b0cae..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:Suppress("unused") - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -private val SelectedColorBlue = Color(0xFF0A84FF) -private val UnselectedColor = Color(0x593C3C3E) -private val TextColor = Color.White -private val IconTint = Color.White - -@Composable -fun ControlCenterButton( - label: String, - icon: Painter, - onClick: () -> Unit, - modifier: Modifier = Modifier, - iconAreaSize: Dp, - isSelected: Boolean, - backgroundBrush: Brush? = null -) { - val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor - val backgroundColor by animateColorAsState( - targetValue = targetBackgroundColor, - label = "ButtonBackground" - ) - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .size(iconAreaSize) - .clip(CircleShape) - .background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor))) - .clickable( - onClick = onClick, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = icon, - contentDescription = null, - tint = IconTint, - modifier = Modifier.size(32.dp) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = label, - color = TextColor, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - maxLines = 2 - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt deleted file mode 100644 index ca0d1f4c8..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt +++ /dev/null @@ -1,242 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.NoiseControlMode - -private val ContainerColor = Color(0x593C3C3E) -private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) -private val SelectedIndicatorColorBlue = Color(0xFF0A84FF) -private val TextColor = Color.White -private val IconTintUnselected = Color.White -private val IconTintSelected = Color.White - -internal val AdaptiveRainbowBrush = Brush.sweepGradient( - colors = listOf( - Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F), - Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D), - Color(0xFFB03A2F) - ) -) - -internal val IconAreaSize = 72.dp -private val IconSize = 42.dp -private val IconRowHeight = IconAreaSize + 12.dp -private val TextRowHeight = 24.dp -private val TextSize = 12.sp - -@Composable -fun ControlCenterNoiseControlSegmentedButton( - modifier: Modifier = Modifier, - availableModes: List, - selectedMode: NoiseControlMode, - onModeSelected: (NoiseControlMode) -> Unit -) { - val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0) - val density = LocalDensity.current - var iconRowWidthPx by remember { mutableFloatStateOf(0f) } - val itemCount = availableModes.size - - val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) { - if (itemCount > 0 && iconRowWidthPx > 0) { - iconRowWidthPx / itemCount - } else { - 0f - } - } - val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } } - val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } } - - val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) { - if (itemSlotWidthPx > 0) { - val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx - slotCenterPx - (iconAreaSizePx / 2f) - } else { - 0f - } - } - - val indicatorOffset: Dp by animateDpAsState( - targetValue = with(density) { targetIndicatorStartPx.toDp() }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "IndicatorOffset" - ) - - val indicatorBackground = remember(selectedMode) { - when (selectedMode) { - NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush - NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray)) - NoiseControlMode.TRANSPARENCY, - NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue)) - } - } - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(IconRowHeight) - .clip(CircleShape) - .background(ContainerColor) - .onSizeChanged { iconRowWidthPx = it.width.toFloat() }, - contentAlignment = Alignment.Center - ) { - Box( - Modifier - .align(Alignment.CenterStart) - .offset(x = indicatorOffset) - .size(IconAreaSize) - .clip(CircleShape) - .background(indicatorBackground) - ) - - Row( - modifier = Modifier.fillMaxWidth().align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround - ) { - availableModes.forEach { mode -> - val isSelected = selectedMode == mode - NoiseControlIconItem( - modifier = Modifier.size(IconAreaSize), - mode = mode, - isSelected = isSelected, - onClick = { onModeSelected(mode) } - ) - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(TextRowHeight), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - availableModes.forEach { mode -> - val isSelected = selectedMode == mode - Text( - text = getModeLabel(mode), - color = TextColor, - fontSize = TextSize, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - textAlign = TextAlign.Center, - modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp)) - ) - } - } - } -} - -@Composable -private fun NoiseControlIconItem( - modifier: Modifier = Modifier, - mode: NoiseControlMode, - isSelected: Boolean, - onClick: () -> Unit -) { - val iconRes = remember(mode) { getModeIconRes(mode) } - - val tint = IconTintUnselected - - Box( - modifier = modifier - .clip(CircleShape) - .clickable( - onClick = onClick, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = getModeLabel(mode), - tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint, - modifier = Modifier.size(IconSize) - ) - } -} - - -private fun getModeIconRes(mode: NoiseControlMode): Int { - return when (mode) { - NoiseControlMode.OFF -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY -> R.drawable.transparency - NoiseControlMode.ADAPTIVE -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation - } -} - -private fun getModeLabel(mode: NoiseControlMode): String { - return when (mode) { - NoiseControlMode.OFF -> "Off" - NoiseControlMode.TRANSPARENCY -> "Transparency" - NoiseControlMode.ADAPTIVE -> "Adaptive" - NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation" - } -} - diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt deleted file mode 100644 index b5a996884..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt +++ /dev/null @@ -1,56 +0,0 @@ -package me.kavishdevar.librepods.presentation.components - -import android.os.Build -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.utils.XposedState - -@Composable -fun DeviceInfoCard() { - StyledList(title = stringResource(R.string.device_info)) { - StyledListItem( - name = stringResource(R.string.manufacturer), - description = Build.MANUFACTURER, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.model_number), - description = Build.MODEL, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.build_id), - description = Build.DISPLAY, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.version), - description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})", - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.xposed_available), - description = if (XposedState.isAvailable) { - stringResource(R.string.yes) - } else { - stringResource(R.string.no) - }, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.app_enabled_in_xposed), - description = if (XposedState.bluetoothScopeEnabled) { - stringResource(R.string.yes) - } else { - stringResource(R.string.no) - }, - enabled = false - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt deleted file mode 100644 index ae5549952..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun HearingHealthSettings( - hasPPECapability: Boolean, - hasHearingAidCapability: Boolean, - vendorIdHook: Boolean, - navigateToHearingProtection: () -> Unit, - navigateToHearingAid: () -> Unit -) { - val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook - - if (hasPPECapability && shouldShowHearingAid) { - StyledList(title = stringResource(R.string.hearing_health)) { - StyledListItem( - name = stringResource(R.string.hearing_protection), - onClick = navigateToHearingProtection - ) - - StyledListItem( - name = stringResource(R.string.hearing_aid), - onClick = navigateToHearingAid - ) - } - } else if (shouldShowHearingAid) { - StyledListItem( - name = stringResource(R.string.hearing_aid), - onClick = navigateToHearingAid - ) - } else if (hasPPECapability) { - StyledListItem( - title = stringResource(R.string.hearing_health), - name = stringResource(R.string.hearing_protection), - onClick = navigateToHearingProtection - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt deleted file mode 100644 index a5b880f52..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.res.imageResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R - -@Composable -fun NoiseControlButton( - icon: ImageBitmap, - onClick: () -> Unit, - textColor: Color, - modifier: Modifier = Modifier, - usePadding: Boolean = true -) { - Column( - modifier = modifier - .fillMaxHeight() - .then(if (usePadding) Modifier.padding(horizontal = 4.dp, vertical = 4.dp) else Modifier) - .clickable( - onClick = onClick, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - bitmap = icon, - contentDescription = null, - tint = textColor, - modifier = Modifier.size(40.dp) - ) - } -} - -@Preview -@Composable -fun NoiseControlButtonPreview() { - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = {}, - textColor = Color.White, - ) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt deleted file mode 100644 index 4fbcf332d..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt +++ /dev/null @@ -1,521 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import android.annotation.SuppressLint -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.ToggleButtonDefaults -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.imageResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.NoiseControlMode -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.sectionHeader -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") -@Composable -fun NoiseControlSettings( - showOffListeningMode: Boolean, - noiseControlModeValue: Int, - onNoiseControlModeChanged: (Int) -> Unit -) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - val options = buildList { - if (showOffListeningMode) { - add( - Triple( - NoiseControlMode.OFF, - R.string.off, - R.drawable.noise_cancellation - ) - ) - } - - add( - Triple( - NoiseControlMode.TRANSPARENCY, - R.string.transparency, - R.drawable.transparency - ) - ) - add( - Triple( - NoiseControlMode.ADAPTIVE, - R.string.adaptive, - R.drawable.adaptive - ) - ) - add( - Triple( - NoiseControlMode.NOISE_CANCELLATION, - R.string.noise_cancellation, - R.drawable.noise_cancellation - ) - ) - } - - val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)] - - Column { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 12.dp) - ) { - Text( - text = stringResource(R.string.noise_control), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - ) { - options.forEachIndexed { index, (mode, labelRes, iconRes) -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f), - ) { - ToggleButton( - checked = selectedMode == mode, - onCheckedChange = { - if (it) { - onNoiseControlModeChanged(mode.ordinal + 1) - } - }, - shapes = when (index) { - 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - colors = ToggleButtonDefaults.toggleButtonColors() - .copy(containerColor = MaterialTheme.colorScheme.surface), - modifier = Modifier.fillMaxWidth() - ) { - Icon( - bitmap = ImageBitmap.imageResource(iconRes), - contentDescription = null, - modifier = Modifier.size(42.dp) - ) - } - - Text( - text = stringResource(labelRes), - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 2, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - } - - DesignSystem.Apple -> { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) - val textColor = if (isDarkTheme) Color.White else Color.Black - val textColorSelected = if (isDarkTheme) Color.White else Color.Black - val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) - - val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } - - val d1a = remember { mutableFloatStateOf(0f) } - val d2a = remember { mutableFloatStateOf(0f) } - val d3a = remember { mutableFloatStateOf(0f) } - - // this function exists solely for the dividers, should get rid of it - fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { - val previousMode = noiseControlMode.value - - val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) { - NoiseControlMode.TRANSPARENCY - } else { - mode - } - - noiseControlMode.value = targetMode - - if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1) - - - when (noiseControlMode.value) { - NoiseControlMode.NOISE_CANCELLATION -> { - d1a.floatValue = 1f - d2a.floatValue = 1f - d3a.floatValue = 0f - } - NoiseControlMode.OFF -> { - d1a.floatValue = 0f - d2a.floatValue = 1f - d3a.floatValue = 1f - } - NoiseControlMode.ADAPTIVE -> { - d1a.floatValue = 1f - d2a.floatValue = 0f - d3a.floatValue = 0f - } - NoiseControlMode.TRANSPARENCY -> { - d1a.floatValue = 0f - d2a.floatValue = 0f - d3a.floatValue = 1f - } - } - } - - - val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1) - noiseControlMode.value = NoiseControlMode.entries[index] - - onModeSelected(noiseControlMode.value, received = true) - - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 4.dp) - ) { - Text( - text = stringResource(R.string.noise_control), - color = MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - val density = LocalDensity.current - val buttonCount = if (showOffListeningMode) 4 else 3 - val buttonWidth = maxWidth / buttonCount - - val isDragging = remember { mutableStateOf(false) } - var dragOffset by remember { - mutableFloatStateOf( - with(density) { - when(noiseControlMode.value) { - NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx() - NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f - NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx() - NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() - } - } - ) - } - - val animationSpec: AnimationSpec = SpringSpec( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = 0.01f - ) - - val targetOffset = buttonWidth * when(noiseControlMode.value) { - NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1 - NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0 - NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1 - NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2 - } - - val animatedOffset by animateFloatAsState( - targetValue = with(density) { - if (isDragging.value) dragOffset else targetOffset.toPx() - }, - animationSpec = animationSpec, - label = "selector" - ) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .background(backgroundColor, RoundedCornerShape(28.dp)) - ) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - if (showOffListeningMode) { - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - } - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - } - - Box( - modifier = Modifier - .width(buttonWidth) - .fillMaxHeight() - .offset { IntOffset(animatedOffset.roundToInt(), 0) } - .zIndex(0f) - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - dragOffset = (dragOffset + delta).coerceIn( - 0f, - with(density) { (buttonWidth * (buttonCount - 1)).toPx() } - ) - }, - onDragStarted = { isDragging.value = true }, - onDragStopped = { - isDragging.value = false - val position = - dragOffset / with(density) { buttonWidth.toPx() } - val newIndex = position.roundToInt() - val newMode = when (newIndex) { - 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY - 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE - 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION - 3 -> NoiseControlMode.NOISE_CANCELLATION - else -> noiseControlMode.value // Keep current if index is invalid - } - onModeSelected(newMode) - } - ) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(3.dp) - .background(selectedBackground, RoundedCornerShape(26.dp)) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - ) { - if (showOffListeningMode) { - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - } - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - ) { - if (showOffListeningMode) { - Text( - text = stringResource(R.string.off), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - } - Text( - text = stringResource(R.string.transparency), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.adaptive), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.noise_cancellation), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - } - } - } - } - } -} - - -@Preview -@Composable -fun NoiseControlSettingsPreview() { - LibrePodsTheme( - m3eEnabled = true - ) { - Box( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - ) { - NoiseControlSettings( - showOffListeningMode = false, - noiseControlModeValue = 2, - onNoiseControlModeChanged = { } - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt deleted file mode 100644 index d0df910e8..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.StemAction - -@Composable -fun PressAndHoldSettings( - leftAction: StemAction, - rightAction: StemAction, - navigateToLeftLongPress: () -> Unit, - navigateToRightLongPress: () -> Unit -) { - val leftActionText = when (leftAction) { - StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) - StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" - else -> "INVALID!!" - } - - val rightActionText = when (rightAction) { - StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) - StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" - else -> "INVALID!!" - } - - StyledList( - title = stringResource(R.string.press_and_hold_airpods) - ) { - StyledListItem( - name = stringResource(R.string.left), - description = leftActionText, - onClick = navigateToLeftLongPress - ) - StyledListItem( - name = stringResource(R.string.right), - description = rightActionText, - onClick = navigateToRightLongPress, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt deleted file mode 100644 index b98139810..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt +++ /dev/null @@ -1,87 +0,0 @@ -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp -import com.kyant.backdrop.backdrops.LayerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.effects.vibrancy - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun StyledBottomSheet( - visible: Boolean, - onDismiss: () -> Unit, - backdrop: LayerBackdrop, - content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit -) { - if (!visible) return - - val isDarkTheme = isSystemInDarkTheme() - val sheetState = rememberModalBottomSheetState(false) // move this to parent composable - - val isExpanded = sheetState.targetValue == SheetValue.Expanded - - val progress by animateFloatAsState( - targetValue = if (isExpanded) 1f else 0f, - label = "sheetProgress" - ) - - val animatedCorner = lerp(48.dp, 42.dp, progress) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = Color.Transparent, - dragHandle = { }, - shape = RoundedCornerShape(animatedCorner), - scrimColor = Color.Transparent, - modifier = Modifier.padding(4.dp) - ) { - val innerBackdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(animatedCorner)) - .drawBackdrop( - backdrop = backdrop, - exportedBackdrop = innerBackdrop, - shape = { RoundedCornerShape(animatedCorner) }, - effects = { - vibrancy() - blur(4f.dp.toPx()) - lens(12f.dp.toPx(), 48f.dp.toPx(), true) - }, - onDrawSurface = { - drawRect( - if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color( - 0xFFE0E0E0 - ).copy(alpha = 0.45f) - ) - } - ) - .padding(top = 24.dp) - .padding(horizontal = 16.dp) - ) { - content(innerBackdrop, progress) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt deleted file mode 100644 index aeafa332e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt +++ /dev/null @@ -1,404 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import android.graphics.RuntimeShader -import android.os.Build -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastCoerceAtMost -import androidx.compose.ui.util.fastCoerceIn -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.effects.vibrancy -import com.kyant.backdrop.highlight.Highlight -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.utils.inspectDragGestures -import kotlin.math.abs -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.tanh - -enum class MaterialButtonStyle { - Tonal, - Normal, - Outlined, - Filled -} - -@Composable -fun StyledButton( - onClick: () -> Unit, - backdrop: Backdrop, - modifier: Modifier = Modifier, - isInteractive: Boolean = true, - tint: Color = Color.Unspecified, - surfaceColor: Color = Color.Unspecified, - maxScale: Float = 0.1f, - enabled: Boolean = true, - materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Tonal, // picking tonal because most usages assume a transparent/gray background, tonal will give a slightly less vibrant background - content: @Composable RowScope.() -> Unit, -) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - when (materialButtonStyle) { - MaterialButtonStyle.Filled -> { - Button( - modifier = modifier.height(48.dp), - onClick = onClick, - content = content - ) - } - MaterialButtonStyle.Tonal -> { - FilledTonalButton( - modifier = modifier.height(48.dp), - onClick = onClick, - content = content, - colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor) - ) - } - - MaterialButtonStyle.Outlined -> { - OutlinedButton( - modifier = modifier.height(48.dp), - onClick = onClick, - content = content - ) - } - - MaterialButtonStyle.Normal -> { - TextButton( - modifier = modifier.height(48.dp), - onClick = onClick, - content = content - ) - } - } - } - DesignSystem.Apple -> { - val isInteractive = enabled && isInteractive - val scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current - val progressAnimation = remember { Animatable(0f) } - var pressStartPosition by remember { mutableStateOf(Offset.Zero) } - val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } - var isPressed by remember { mutableStateOf(false) } - - val interactiveHighlightShader = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ -uniform float2 size; -layout(color) uniform half4 color; -uniform float radius; -uniform float2 offset; - -half4 main(float2 coord) { - float2 center = offset; - float dist = distance(coord, center); - float intensity = smoothstep(radius, radius * 0.5, dist); - return color * intensity; -}""" - ) - } else { - null - } - } - - Row( - modifier - .then( - if (!isInteractive) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { RoundedCornerShape(28f.dp) }, - effects = { - blur(16f.dp.toPx()) - }, - layerBlock = null, - onDrawSurface = { - if (tint.isSpecified) { - drawRect(tint, blendMode = BlendMode.Hue) - drawRect(tint.copy(alpha = 0.75f)) - } else { - drawRect(Color.White.copy(0.1f)) - } - if (surfaceColor.isSpecified && enabled) { - val color = if (isPressed) { - Color( - red = surfaceColor.red * 0.5f, - green = surfaceColor.green * 0.5f, - blue = surfaceColor.blue * 0.5f, - alpha = surfaceColor.alpha - ) - } else { - surfaceColor - } - drawRect(color) - } else { - if (isPressed && enabled) { - drawRect(Color.Black.copy(alpha = 0.4f)) - drawRect(Color.White.copy(alpha = 0.2f)) - } - } - }, - onDrawFront = null, - highlight = { Highlight.Ambient.copy(alpha = 0f) } - ) - } else { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { RoundedCornerShape(28f.dp) }, - effects = { - vibrancy() - blur(2f.dp.toPx()) - lens( - refractionHeight = 12f.dp.toPx(), - refractionAmount = 24f.dp.toPx(), - depthEffect = true, - chromaticAberration = true - ) - }, - layerBlock = { - val width = size.width - val height = size.height - - val progress = progressAnimation.value - val scale = lerp(1f, 1f + maxScale, progress) - - val maxOffset = size.minDimension - val initialDerivative = 0.05f - val offset = offsetAnimation.value - translationX = - maxOffset * tanh(initialDerivative * offset.x / maxOffset) - translationY = - maxOffset * tanh(initialDerivative * offset.y / maxOffset) - - val maxDragScale = 0.1f - val offsetAngle = atan2(offset.y, offset.x) - scaleX = - scale + - maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * - (width / height).fastCoerceAtMost(1f) - scaleY = - scale + - maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * - (height / width).fastCoerceAtMost(1f) - }, - onDrawSurface = { - if (tint.isSpecified) { - drawRect(tint, blendMode = BlendMode.Hue) - drawRect(tint.copy(alpha = 0.75f)) - } else { - drawRect(Color.White.copy(0.1f)) - } - if (surfaceColor.isSpecified) { - val color = if (!isInteractive && isPressed) { - Color( - red = surfaceColor.red * 0.5f, - green = surfaceColor.green * 0.5f, - blue = surfaceColor.blue * 0.5f, - alpha = surfaceColor.alpha - ) - } else { - surfaceColor - } - drawRect(color) - } - }, - onDrawFront = { - val progress = progressAnimation.value.fastCoerceIn(0f, 1f) - if (progress > 0f) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { - drawRect( - Color.White.copy(0.1f * progress), - blendMode = BlendMode.Plus - ) - interactiveHighlightShader.apply { - val offset = - pressStartPosition + offsetAnimation.value - setFloatUniform("size", size.width, size.height) - setColorUniform( - "color", - Color.White.copy(0.15f * progress).toArgb() - ) - setFloatUniform("radius", size.maxDimension) - setFloatUniform( - "offset", - offset.x.fastCoerceIn(0f, size.width), - offset.y.fastCoerceIn(0f, size.height) - ) - } - drawRect( - ShaderBrush(interactiveHighlightShader), - blendMode = BlendMode.Plus - ) - } else { - drawRect( - Color.White.copy(0.25f * progress), - blendMode = BlendMode.Plus - ) - } - } - } - ) - } - ) - .clickable( - interactionSource = null, - indication = null, - role = Role.Button, - onClick = { - if (enabled) { - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() - } - } - ) - .then( - if (isInteractive) { - Modifier.pointerInput(scope) { - val progressAnimationSpec = spring(0.5f, 300f, 0.001f) - val offsetAnimationSpec = - spring(1f, 300f, Offset.VisibilityThreshold) - val onDragStop: () -> Unit = { - if (enabled) { - scope.launch { - launch { - haptics.performHapticFeedback( - HapticFeedbackType.Reject - ) - } - launch { - progressAnimation.animateTo( - 0f, - progressAnimationSpec - ) - } - launch { - offsetAnimation.animateTo( - Offset.Zero, - offsetAnimationSpec - ) - } - } - } - } - inspectDragGestures( - onDragStart = { down -> - pressStartPosition = down.position - if (enabled) { - scope.launch { - launch { - haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - } - launch { - progressAnimation.animateTo( - 1f, - progressAnimationSpec - ) - } - launch { offsetAnimation.snapTo(Offset.Zero) } - } - } - }, - onDragEnd = { - onDragStop() - }, - onDragCancel = onDragStop - ) { _, dragAmount -> - if (enabled) { - scope.launch { - if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - offsetAnimation.snapTo(offsetAnimation.value + dragAmount) - } - } - } - } - } else { - Modifier.pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed = true - tryAwaitRelease() - isPressed = false - }, - onTap = { - if (enabled) { - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() - } - } - ) - } - } - ) - .height(48f.dp) - .padding(horizontal = 16f.dp), - horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - content = content - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt deleted file mode 100644 index ecd3765c1..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt +++ /dev/null @@ -1,437 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.RuntimeShader -import android.os.Build -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedIconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.drawOutline -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.graphics.layer.CompositingStrategy -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastCoerceAtMost -import androidx.compose.ui.util.fastCoerceIn -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.backdrops.LayerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.InnerShadow -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.utils.inspectDragGestures -import kotlin.math.abs -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.tanh - -@Composable -fun StyledIconButton( - modifier: Modifier = Modifier, - icon: String, - iconTint: Color = Color.Unspecified, - surfaceColor: Color = Color.Unspecified, - backdrop: LayerBackdrop = rememberLayerBackdrop(), - onClick: () -> Unit, - enabled: Boolean = true, - materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal -) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - when (materialButtonStyle) { - MaterialButtonStyle.Tonal -> { - FilledTonalIconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(52.dp) - ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - MaterialButtonStyle.Filled -> { - FilledIconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(52.dp) - ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - MaterialButtonStyle.Outlined -> { - OutlinedIconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(52.dp) - ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - MaterialButtonStyle.Normal -> { - IconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(52.dp) - ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - } - } - DesignSystem.Apple -> { - val haptics = LocalHapticFeedback.current - val darkMode = isSystemInDarkTheme() - val scope = rememberCoroutineScope() - val progressAnimationSpec = spring(0.5f, 300f, 0.001f) - val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) - val progressAnimation = remember { Animatable(0f) } - val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } - var pressStartPosition by remember { mutableStateOf(Offset.Zero) } - val innerShadowLayer = rememberGraphicsLayer().apply { - compositingStrategy = CompositingStrategy.Offscreen - } - val density = LocalDensity.current - - val interactiveHighlightShader = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ -uniform float2 size; -layout(color) uniform half4 color; -uniform float radius; -uniform float2 offset; - -half4 main(float2 coord) { - float2 center = offset; - float dist = distance(coord, center); - float intensity = smoothstep(radius, radius * 0.5, dist); - return color * intensity; -}""" - ) - } else { - null - } - } - val isDarkTheme = isSystemInDarkTheme() - TextButton( - onClick = { - if (enabled) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } - onClick() - } - }, - shape = RoundedCornerShape(56.dp), - modifier = modifier - .padding(horizontal = 12.dp) - .drawBackdrop( - backdrop = backdrop, - shape = { RoundedCornerShape(56.dp) }, - highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, - innerShadow = { - if (isDarkTheme) { - InnerShadow( - radius = 0.5.dp, - offset = DpOffset(1.dp, 1.dp), - color = Color.White.copy(0.6f), - ) - } else InnerShadow() - }, - layerBlock = { - if (!enabled) return@drawBackdrop - val width = size.width - val height = size.height - - val progress = progressAnimation.value - val scale = lerp(1f, 1.5f, progress) - - val maxOffset = size.minDimension - val initialDerivative = 0.05f - val offset = offsetAnimation.value - translationX = - maxOffset * tanh(initialDerivative * offset.x / maxOffset) - translationY = - maxOffset * tanh(initialDerivative * offset.y / maxOffset) - - val maxDragScale = 0.1f - val offsetAngle = atan2(offset.y, offset.x) - scaleX = - scale + - maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * - (width / height).fastCoerceAtMost(1f) - scaleY = - scale + - maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * - (height / width).fastCoerceAtMost(1f) - }, - onDrawSurface = { - if (!enabled) { - drawRect( - (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f) - ) - return@drawBackdrop - } - val progress = progressAnimation.value.coerceIn(0f, 1f) - - val shape = RoundedCornerShape(56.dp) - val outline = shape.createOutline(size, layoutDirection, this) - val innerShadowOffset = 4f.dp.toPx() - val innerShadowBlurRadius = 4f.dp.toPx() - - innerShadowLayer.alpha = progress - innerShadowLayer.renderEffect = - BlurEffect( - innerShadowBlurRadius, - innerShadowBlurRadius, - TileMode.Decal - ) - innerShadowLayer.record { - drawOutline(outline, Color.Black.copy(0.2f)) - translate(0f, innerShadowOffset) { - drawOutline( - outline, - Color.Transparent, - blendMode = BlendMode.Clear - ) - } - } - drawLayer(innerShadowLayer) - - if (surfaceColor.isSpecified) { - drawRect(surfaceColor) - } - - if (!isDarkTheme) { - drawOutline( - outline = outline, - color = Color.Black.copy(0.4f), - style = Stroke(1f), - ) - } - - drawRect( - (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy( - progress.coerceIn( - 0.15f, - 0.35f - ) - ) - ) - }, - onDrawFront = { - if (!enabled) return@drawBackdrop - val progress = progressAnimation.value.fastCoerceIn(0f, 1f) - if (progress > 0f) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { - drawRect( - Color.White.copy(0.1f * progress), - blendMode = BlendMode.Plus - ) - interactiveHighlightShader.apply { - val offset = pressStartPosition + offsetAnimation.value - setFloatUniform("size", size.width, size.height) - setColorUniform( - "color", - Color.White.copy(0.15f * progress).toArgb() - ) - setFloatUniform("radius", size.maxDimension) - setFloatUniform( - "offset", - offset.x.fastCoerceIn(0f, size.width), - offset.y.fastCoerceIn(0f, size.height) - ) - } - drawRect( - ShaderBrush(interactiveHighlightShader), - blendMode = BlendMode.Plus - ) - } else { - drawRect( - Color.White.copy(0.25f * progress), - blendMode = BlendMode.Plus - ) - } - } - }, - effects = { - lens( - refractionHeight = 6f.dp.toPx(), - refractionAmount = size.height / 2f, - depthEffect = true, - chromaticAberration = true - ) - }, - ) - .pointerInput(scope) { - val onDragStop: () -> Unit = { - if (enabled) { - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } - launch { - progressAnimation.animateTo( - 0f, - progressAnimationSpec - ) - } - launch { - offsetAnimation.animateTo( - Offset.Zero, - offsetAnimationSpec - ) - } - } - } - } - inspectDragGestures( - onDragStart = { down -> - if (enabled) { - pressStartPosition = down.position - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } - launch { - progressAnimation.animateTo( - 1f, - progressAnimationSpec - ) - } - launch { offsetAnimation.snapTo(Offset.Zero) } - } - } - }, - onDragEnd = { onDragStop() }, - onDragCancel = onDragStop - ) { _, dragAmount -> - scope.launch { - if (enabled) { - if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - offsetAnimation.snapTo(offsetAnimation.value + dragAmount) - } - } - } - } - .size(with(density) { 48.sp.toDp() }), - ) { - Text( - text = icon, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Normal, - color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO, name = "Light") -@Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark") -@Composable -fun StyledIconButtonPreview() { - Box(modifier = Modifier - .height(120.dp) - .width(200.dp) - .background( - if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), - RoundedCornerShape(28.dp) - ), contentAlignment = Alignment.Center) { - StyledIconButton( - icon = "􀍟", - onClick = { } - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt deleted file mode 100644 index fa99e6cd8..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt +++ /dev/null @@ -1,178 +0,0 @@ -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem - - -@Composable -fun StyledInputField( - inputState: TextFieldState, - focusRequester: FocusRequester, - placeholder: String = "", - singleLine: Boolean = true, - forceApple: Boolean = false -) { - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple - - if(m3eEnabled) { - TextField( - state = inputState, - placeholder = { - Text( - text = placeholder, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - }, - lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) - } - else { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black - val minHeight = if (singleLine) 58.dp else 120.dp - val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top - val hasText = inputState.text.isNotEmpty() - val density = LocalDensity.current - val spacerHeight by animateDpAsState( - targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp, - label = "labelSpacer" - ) - - val transition = updateTransition(hasText, label = "floating") - val yOffset by transition.animateDp(label = "y") { - if (it) with(density) { (-48).sp.toDp() } else 0.dp - } - - Spacer(modifier = Modifier.height(spacerHeight)) - - Box( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = verticalAlignment, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = minHeight) - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - .pointerInput(Unit) { - detectTapGestures { - focusRequester.requestFocus() - } - } - ) { - BasicTextField( - state = inputState, - lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default, - textStyle = TextStyle( - fontSize = 16.sp, - color = textColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - cursorBrush = SolidColor(textColor), - decorator = { innerTextField -> - Row( - modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp), - verticalAlignment = verticalAlignment, - ) { - Row( - modifier = Modifier - .weight(1f) - ) { - Box( - modifier = Modifier - .weight(1f), - contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart - ) { - Text( - text = placeholder, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Light, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.8f) - ), - modifier = Modifier - .offset(y = yOffset) - ) - - innerTextField() - } - } - if (singleLine && !inputState.text.isEmpty()) { - IconButton( - onClick = { - inputState.clearText() - } - ) { - Text( - text = "􀁡", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( - alpha = 0.6f - ) - ), - ) - } - } - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp) - .focusRequester(focusRequester) - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt deleted file mode 100644 index 1edcd893b..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt +++ /dev/null @@ -1,132 +0,0 @@ -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.sectionHeader - -@Composable -fun StyledList( - modifier: Modifier = Modifier, - title: String? = null, - description: String? = null, - content: @Composable StyledListScope.() -> Unit -) { - val scope = StyledListScope() - scope.content() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - Column (modifier = modifier) { - title?.let { - Box( - modifier = Modifier - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp) - ) { - Text( - text = it, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - } - Column( - modifier = Modifier - .fillMaxWidth() - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) - .clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) - ) { - if (m3eEnabled && description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(0.8f), - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - } - scope.items.forEachIndexed { index, item -> - item(index, scope.items.size) - } - Spacer(modifier = Modifier.height(if(m3eEnabled) 4.dp else 0.dp)) - } - } - if (!m3eEnabled && description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmallEmphasized, - color = MaterialTheme.colorScheme.onBackground.copy(0.6f), - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - } -} - -class StyledListScope { - internal val items = - mutableListOf<@Composable (Int, Int) -> Unit>() - - fun item( - content: @Composable (index: Int, count: Int) -> Unit - ) { - items += content - } -} - -@Preview(showBackground = true, wallpaper = GREEN_DOMINATED_EXAMPLE, uiMode = UI_MODE_NIGHT_YES) -@Composable -fun StyledListDemo() { - LibrePodsTheme( - m3eEnabled = true - ) { - val backgroundC = MaterialTheme.colorScheme.background - StyledScaffold( - title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}" - ) { - Column ( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(56.dp)) - StyledList( - title = "hello" - ) { - for (i in 0..2) { - StyledListItem( - name = i.toString(), - onClick = {} - ) - } - val checked = remember { mutableStateOf(false) } - StyledToggle( - label = "Test", - description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit mollit anim id est laborum.", - checked = checked.value, - onCheckedChange = { checked.value = it }, - ) - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt deleted file mode 100644 index 7a92106e4..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt +++ /dev/null @@ -1,409 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.sectionHeader - -@Composable -fun StyledListItem( - modifier: Modifier = Modifier, - title: String? = null, - name: String, - onClick: (() -> Unit)?, - description: String? = null, - height: Dp = 58.dp, - enabled: Boolean = true, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - Column { - title?.let { - Box( - modifier = Modifier - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) - ) { - Text( - text = it, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - } - Column( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) - ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) - ) { - StyledListItemContent( - name = name, - onClick = onClick, - description = description, - height = height, - enabled = enabled, - index = 0, - count = 1, - orientation = orientation, - leadingContent = leadingContent, - trailingContent = trailingContent - ) - } - } -} - -@Composable -fun StyledListScope.StyledListItem( - modifier: Modifier = Modifier, - name: String, - onClick: (() -> Unit)? = null, - description: String? = null, - enabled: Boolean = onClick != null, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - selected: Boolean? = null, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - item { index, count -> - StyledListItemContent( - name = name, - onClick = onClick, - description = description, - enabled = enabled, - index = index, - count = count, - orientation = orientation, - modifier = modifier, - selected = selected, - leadingContent = leadingContent, - trailingContent = trailingContent - ) - } -} - -enum class ListItemOrientation{ - Horizontal, - Vertical -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun StyledListItemContent( - modifier: Modifier = Modifier, - name: String, - onClick: (() -> Unit)?, - description: String? = null, - height: Dp = 58.dp, - enabled: Boolean = true, - index: Int, - count: Int, - orientation: ListItemOrientation = ListItemOrientation.Horizontal, - selected: Boolean? = null, - leadingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - val isDarkTheme = isSystemInDarkTheme() - val surfaceColor = MaterialTheme.colorScheme.surface - val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim - var backgroundColor by remember { mutableStateOf(surfaceColor) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - val haptics = LocalHapticFeedback.current - val scope = rememberCoroutineScope() - - when (LocalDesignSystem.current) { - DesignSystem.Apple -> { - val trailingContentDefault: @Composable () -> Unit = { - if (trailingContent == null) { - if (onClick != null) { - if (selected != null) { - val floatAnimateState by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(durationMillis = 300) - ) - - Text( - text = "􀆅", - style = TextStyle( - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState), - ), - modifier = Modifier.padding(end = 4.dp) - ) - } else { - Text( - text = "􀯻", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(0.6f), - modifier = Modifier - .padding(start = if (description != null) 6.dp else 0.dp) - ) - } - } - } else { - trailingContent() - } - } - Column ( - modifier = Modifier - .background( - animatedBackgroundColor, - when { - (index == 0 && count == 1) -> { - RoundedCornerShape(28.dp) - } - - (index == 0) -> { - RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = 0.dp, - bottomEnd = 0.dp - ) - } - - (index + 1 == count) -> { - RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 28.dp, - bottomEnd = 28.dp - ) - } - - else -> { - RectangleShape - } - } - ) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - if (enabled) { - backgroundColor = surfaceDimColor - tryAwaitRelease() - backgroundColor = surfaceColor - } - }, - onTap = { - if (enabled) { - scope.launch { - haptics.performHapticFeedback( - HapticFeedbackType.ContextClick - ) - } - onClick?.invoke() - } - } - ) - } - .heightIn(min = height) - .padding(horizontal = 16.dp) - ) { - Row( - modifier = Modifier - .heightIn(min = height) - .padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (leadingContent != null) { - leadingContent() - Spacer(modifier = Modifier.width(12.dp)) - } - Column (verticalArrangement = Arrangement.Center) { - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (description != null && orientation == ListItemOrientation.Vertical) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - if (orientation == ListItemOrientation.Horizontal && description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme - ) - } - - trailingContentDefault() - } - if (index+1 != count) { - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(start = if (leadingContent != null) 12.dp else 0.dp) - ) - } - } - } - - DesignSystem.Material -> { - val defaultShape = when { - count == 1 -> RoundedCornerShape(24.dp) - - index == 0 -> RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomStart = 8.dp, - bottomEnd = 8.dp - ) - - index == count - 1 -> RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp, - bottomStart = 24.dp, - bottomEnd = 24.dp - ) - - else -> RoundedCornerShape(8.dp) - } - Column { - SegmentedListItem( - modifier = modifier.heightIn(min = 64.dp), - shapes = ListItemDefaults.shapes().copy( - shape = defaultShape, - pressedShape = RoundedCornerShape(24.dp), - selectedShape = RoundedCornerShape(24.dp), - hoveredShape = RoundedCornerShape(24.dp), - ), - onClick = onClick ?: {}, - leadingContent = leadingContent, - trailingContent = { - if (trailingContent == null) { - if (onClick != null) { - if (selected == true) { - Icon(Icons.Default.Check, contentDescription = null) - } else if (selected == null) { - Icon( - Icons.AutoMirrored.Default.KeyboardArrowRight, - contentDescription = null - ) - } - } - } else { - trailingContent() - } - }, - supportingContent = { - if (description != null) Text( - description, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 4.dp) - ) - }, - content = { - Text( - text = name, - style = MaterialTheme.typography.labelMediumEmphasized, - modifier = Modifier.padding( - top = 4.dp, - bottom = if (description != null) 0.dp else 4.dp - ) - ) - }, - verticalAlignment = Alignment.CenterVertically, - colors = if (onClick == null) { - ListItemDefaults.segmentedColors().run { - copy( - disabledContentColor = contentColor, - disabledSupportingContentColor = supportingContentColor, - disabledTrailingContentColor = trailingContentColor - ) - } - } else ListItemDefaults.segmentedColors(), - enabled = onClick != null && enabled, - selected = selected ?: false - ) - if (index+1 != count) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt deleted file mode 100644 index 656dd1e00..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt +++ /dev/null @@ -1,295 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import com.kyant.backdrop.backdrops.LayerBackdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun StyledScaffold( - modifier: Modifier = Modifier, - visible: Boolean = true, - title: String, - showBackButton: Boolean = false, - onNavigateBack: () -> Unit = {}, - actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - content: @Composable () -> Unit -) { - val isDarkTheme = isSystemInDarkTheme() - val hazeState = rememberHazeState(blurEnabled = true) - - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - AnimatedVisibility( - visible = visible, - enter = fadeIn() + slideInVertically(initialOffsetY = { -it }), - exit = fadeOut() + slideOutVertically(targetOffsetY = { -it }) - ) { - TopAppBar( - navigationIcon = { - if (showBackButton) { - Row { - Spacer(modifier = Modifier.width(12.dp)) - FilledTonalIconButton( - onClick = onNavigateBack, - modifier = Modifier - .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), - shape = IconButtonDefaults.mediumRoundShape - ) { - Icon( - Icons.AutoMirrored.Default.ArrowBack, - contentDescription = "", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize), - ) - } - } - } - }, - title = { - Crossfade(targetState = title) { - Text( - text = it, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp), - style = MaterialTheme.typography.titleSmall - ) - } - }, - actions = { - actionButtons.forEach { actionButton -> - actionButton(rememberLayerBackdrop()) - } - Spacer(modifier = Modifier.width(12.dp)) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) - } - }, - ) { paddingValues -> - Box( - modifier = modifier - .then(if (visible) Modifier.padding(paddingValues) else Modifier) - .fillMaxSize() - .hazeSource(hazeState) - ) { - content() - } - } - } - DesignSystem.Apple -> { - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - snackbarHost = { SnackbarHost(snackbarHostState) }, - modifier = Modifier - .then( - if (!isDarkTheme) Modifier.shadow( - elevation = 36.dp, - shape = RoundedCornerShape(52.dp), - ambientColor = Color.Black, - spotColor = Color.Black - ) else Modifier - ) - .clip(RoundedCornerShape(52.dp)) - ) { paddingValues -> - val topPadding = paddingValues.calculateTopPadding() - val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) - val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = startPadding, end = endPadding) - ) { - val backdrop = rememberLayerBackdrop() - val bgColor = MaterialTheme.colorScheme.surfaceContainer - AnimatedVisibility( - visible = showBackButton, - enter = fadeIn() + scaleIn( - initialScale = 0f, - animationSpec = tween() - ), - exit = fadeOut() + scaleOut( - targetScale = 0.5f, - animationSpec = tween(100) - ), - modifier = Modifier - .zIndex(3f) - .padding(top = topPadding, start = 8.dp) - .align(Alignment.TopStart) - ) { - StyledIconButton( - onClick = onNavigateBack, - icon = "􀯶", - backdrop = backdrop - ) - } - - AnimatedVisibility( - visible = visible, - enter = fadeIn() + scaleIn( - initialScale = 0f, - animationSpec = tween() - ), - exit = fadeOut() + scaleOut( - targetScale = 0.5f, - animationSpec = tween(100) - ), - modifier = Modifier - .zIndex(2f) - .height(64.dp + topPadding) - .fillMaxWidth() - .layerBackdrop(backdrop) - ){ - Box( - modifier = Modifier.hazeEffect( - state = hazeState, - ) { - backgroundColor = bgColor - tints = listOf( - HazeTint( - if (isDarkTheme) Color.Black.copy(0.55f) else Color( - 0xFFF2F2F7 - ).copy(alpha = 0.85f) - ) - ) - blurRadius = 6.dp - } - ) { - - Column(modifier = Modifier.fillMaxSize()) { - Spacer(modifier = Modifier.height(topPadding + 12.dp)) - Crossfade(targetState = title) { - Text( - text = it, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } - } - } - - AnimatedVisibility( - visible = visible && actionButtons.isNotEmpty(), - enter = fadeIn() + scaleIn( - initialScale = 0f, - animationSpec = tween() - ), - exit = fadeOut() + scaleOut( - targetScale = 0.5f, - animationSpec = tween(100) - ), - modifier = Modifier - .zIndex(3f) - .padding(top = topPadding, end = 8.dp) - .align(Alignment.TopEnd) - ) { - Row{ - actionButtons.forEach { actionButton -> - actionButton(backdrop) - } - } - } - - Box( - modifier = modifier - .hazeSource(hazeState) - .fillMaxSize() - ) { - content() - } - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt deleted file mode 100644 index 5ce29a1cb..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt +++ /dev/null @@ -1,808 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import android.annotation.SuppressLint -import android.content.res.Configuration -import android.util.Log -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.util.VelocityTracker -import androidx.compose.ui.input.pointer.util.addPointerInputChange -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastCoerceIn -import androidx.compose.ui.util.fastRoundToInt -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberCombinedBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.InnerShadow -import com.kyant.backdrop.shadow.Shadow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.utils.inspectDragGestures -import kotlin.math.abs -import kotlin.math.roundToInt - -@Composable -fun rememberMomentumAnimation( - maxScale: Float, - progressAnimationSpec: FiniteAnimationSpec = - spring(1f, 1000f, 0.01f), - velocityAnimationSpec: FiniteAnimationSpec = - spring(0.5f, 250f, 5f), - scaleXAnimationSpec: FiniteAnimationSpec = - spring(0.4f, 400f, 0.01f), - scaleYAnimationSpec: FiniteAnimationSpec = - spring(0.6f, 400f, 0.01f) -): MomentumAnimation { - val animationScope = rememberCoroutineScope() - return remember( - maxScale, - animationScope, - progressAnimationSpec, - velocityAnimationSpec, - scaleXAnimationSpec, - scaleYAnimationSpec - ) { - MomentumAnimation( - maxScale = maxScale, - animationScope = animationScope, - progressAnimationSpec = progressAnimationSpec, - velocityAnimationSpec = velocityAnimationSpec, - scaleXAnimationSpec = scaleXAnimationSpec, - scaleYAnimationSpec = scaleYAnimationSpec - ) - } -} - -class MomentumAnimation( - val maxScale: Float, - private val animationScope: CoroutineScope, - private val progressAnimationSpec: FiniteAnimationSpec, - private val velocityAnimationSpec: FiniteAnimationSpec, - private val scaleXAnimationSpec: FiniteAnimationSpec, - private val scaleYAnimationSpec: FiniteAnimationSpec -) { - - private val velocityTracker = VelocityTracker() - - private val progressAnimation = Animatable(0f) - private val velocityAnimation = Animatable(0f) - private val scaleXAnimation = Animatable(1f) - private val scaleYAnimation = Animatable(1f) - - val progress: Float get() = progressAnimation.value - val velocity: Float get() = velocityAnimation.value - val scaleX: Float get() = scaleXAnimation.value - val scaleY: Float get() = scaleYAnimation.value - - var isDragging: Boolean by mutableStateOf(false) - private set - - val modifier: Modifier = Modifier.pointerInput(Unit) { - inspectDragGestures( - onDragStart = { - isDragging = true - velocityTracker.resetTracking() - startPressingAnimation() - }, - onDragEnd = { change -> - isDragging = false - val velocity = velocityTracker.calculateVelocity() - updateVelocity(velocity) - velocityTracker.addPointerInputChange(change) - velocityTracker.resetTracking() - endPressingAnimation() - settleVelocity() - }, - onDragCancel = { - isDragging = false - velocityTracker.resetTracking() - endPressingAnimation() - settleVelocity() - } - ) { change, _ -> - isDragging = true - velocityTracker.addPointerInputChange(change) - val velocity = velocityTracker.calculateVelocity() - updateVelocity(velocity) - } - } - - private fun updateVelocity(velocity: Velocity) { - animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) } - } - - private fun settleVelocity() { - animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } - } - - fun startPressingAnimation() { - animationScope.launch { - launch { progressAnimation.animateTo(1f, progressAnimationSpec) } - launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) } - launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) } - } - } - - fun endPressingAnimation() { - animationScope.launch { - launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) } - launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) } - } - } -} - -@SuppressLint("UnrememberedMutableState") -@Composable -fun StyledSlider( - label: String? = null, - value: Float, - onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange, - backdrop: Backdrop = rememberLayerBackdrop(), - snapPoints: List = emptyList(), - snapThreshold: Float = 0.05f, - startIcon: String? = null, - endIcon: String? = null, - startLabel: String? = null, - endLabel: String? = null, - independent: Boolean = false, - description: String? = null, - enabled: Boolean = true, - index: Int = 0, - count: Int = 1 -) { - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - val defaultShape = when { - count == 1 -> RoundedCornerShape(24.dp) - - index == 0 -> RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomStart = 8.dp, - bottomEnd = 8.dp - ) - - index == count - 1 -> RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp, - bottomStart = 24.dp, - bottomEnd = 24.dp - ) - - else -> RoundedCornerShape(8.dp) - } - - Column { - label?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelSmallEmphasized, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 12.dp) - ) - } - SegmentedListItem( - shapes = ListItemDefaults.shapes().copy( - shape = defaultShape, - pressedShape = RoundedCornerShape(24.dp), - selectedShape = RoundedCornerShape(24.dp), - hoveredShape = RoundedCornerShape(24.dp), - ), - onClick = {}, - enabled = enabled, - modifier = Modifier.heightIn(min = 58.dp), - content = { - Column( - modifier = Modifier.fillMaxWidth() - ) { - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - - if (startLabel != null || endLabel != null) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - startLabel?.let { - Text( - text = it, - style = MaterialTheme.typography.labelSmall - ) - } - - Spacer(Modifier.weight(1f)) - - endLabel?.let { - Text( - text = it, - style = MaterialTheme.typography.labelSmall - ) - } - } - } - } - }, - supportingContent = { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - - startIcon?.let { - Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) - Spacer(Modifier.width(12.dp)) - } - - Slider( - modifier = Modifier.weight(1f), - value = value, - onValueChange = { newValue -> - val snapped = - if (snapPoints.isNotEmpty()) { - snapIfClose( - newValue, - snapPoints, - snapThreshold - ) - } else { - newValue - } - - onValueChange(snapped) - }, - valueRange = valueRange, - enabled = enabled - ) - - endIcon?.let { - Spacer(Modifier.width(12.dp)) - Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) - } - } - } - } - ) - - if (index + 1 != count) { - Spacer( - modifier = Modifier.height(2.dp) - ) - } - } - } - - DesignSystem.Apple -> { - val backgroundColor = - if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val isDarkTheme = isSystemInDarkTheme() - val trackColor = - if (isDarkTheme) Color(0xFF787880).copy(0.36f) - else Color(0xFF787878).copy(0.2f) - val accentColor = - if (enabled) { - if (isDarkTheme) Color(0xFF0091FF) - else Color(0xFF0088FF) - } else { - trackColor - } - val labelTextColor = if (isDarkTheme) Color.White else Color.Black - - val fraction by derivedStateOf { - ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) - .fastCoerceIn(0f, 1f) - } - - val sliderBackdrop = rememberLayerBackdrop() - val trackWidthState = remember { mutableFloatStateOf(0f) } - val trackPositionState = remember { mutableFloatStateOf(0f) } - val startIconWidthState = remember { mutableFloatStateOf(0f) } - val endIconWidthState = remember { mutableFloatStateOf(0f) } - val density = LocalDensity.current - val haptics = LocalHapticFeedback.current - var lastDragValue by remember { mutableFloatStateOf(value) } - - val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) - - val content = @Composable { - Box( - Modifier - .fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f) - ) { - Box( - Modifier - .padding(vertical = 4.dp) - .layerBackdrop(sliderBackdrop) - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth(1f) - .padding(vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (startLabel != null || endLabel != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = startLabel ?: "", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - Text( - text = endLabel ?: "", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - Spacer(modifier = Modifier.height(12.dp)) - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .then( - if (startIcon == null && endIcon == null) Modifier.padding( - horizontal = 8.dp - ) else Modifier - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(0.dp) - ) { - if (startIcon != null) { - Text( - text = startIcon, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Normal, - color = accentColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier - .padding(horizontal = 12.dp) - .onGloballyPositioned { - startIconWidthState.floatValue = - it.size.width.toFloat() - } - ) - } - Box( - Modifier - .weight(1f) - .onSizeChanged { - trackWidthState.floatValue = it.width.toFloat() - } - .onGloballyPositioned { - trackPositionState.floatValue = - it.positionInParent().y + it.size.height / 2f - } - ) { - Box( - Modifier - .clip(RoundedCornerShape(28.dp)) - .background(trackColor) - .height(6f.dp) - .fillMaxWidth() - ) - - Box( - Modifier - .clip(RoundedCornerShape(28.dp)) - .background(accentColor) - .height(6f.dp) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - val fraction = fraction - val width = - (fraction * constraints.maxWidth).fastRoundToInt() - layout(width, placeable.height) { - placeable.place(0, 0) - } - } - ) - } - if (endIcon != null) { - Text( - text = endIcon, - style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Normal, - color = accentColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier - .padding(horizontal = 12.dp) - .onGloballyPositioned { - endIconWidthState.floatValue = - it.size.width.toFloat() - } - ) - } - } - if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer( - modifier = Modifier.height(4.dp) - ) - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - if (snapPoints.isNotEmpty()) { - val trackWidth = - if (startIcon != null && endIcon != null) trackWidthState.floatValue - with( - density - ) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with( - density - ) { 22.dp.toPx() } - val startOffset = - if (startIcon != null) startIconWidthState.floatValue + with( - density - ) { 34.dp.toPx() } else with(density) { 14.dp.toPx() } - Box( - Modifier - .fillMaxWidth() - ) { - snapPoints.forEach { point -> - val pointFraction = - ((point - valueRange.start) / (valueRange.endInclusive - valueRange.start)) - .fastCoerceIn(0f, 1f) - Box( - Modifier - .graphicsLayer { - translationX = - startOffset + pointFraction * trackWidth - 4.dp.toPx() - } - .size(2.dp) - .background( - trackColor, - CircleShape - ) - ) - } - } - } - } - } - } - } - - Box( - Modifier - .graphicsLayer { - val startOffset = - if (startIcon != null) - startIconWidthState.floatValue + with(density) { 24.dp.toPx() } - else - with(density) { 8.dp.toPx() } - - translationX = - (startOffset + fraction * trackWidthState.floatValue - size.width / 2f) - .fastCoerceIn( - startOffset - size.width / 4f, - startOffset + trackWidthState.floatValue - size.width * 3f / 4f - ) - translationY = - if (startLabel != null || endLabel != null) trackPositionState.floatValue + with( - density - ) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with( - density - ) { 8.dp.toPx() } - } - .then( - if (enabled) { - Modifier - .draggable( - rememberDraggableState { delta -> - val trackWidth = trackWidthState.floatValue - if (trackWidth > 0f) { - val targetFraction = - fraction + delta / trackWidth - val targetValue = - lerp( - valueRange.start, - valueRange.endInclusive, - targetFraction - ) - .fastCoerceIn( - valueRange.start, - valueRange.endInclusive - ) - snapPoints.forEach { snap -> - if ((lastDragValue < snap && targetValue >= snap) || - (snap in targetValue.., threshold: Float = 0.05f): Float { - val nearest = points.minByOrNull { abs(it - value) } ?: value - return if (abs(nearest - value) <= threshold) nearest else value -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE) -@Composable -fun StyledSliderPreview() { - val a = remember { mutableFloatStateOf(0.5f) } - LibrePodsTheme( - m3eEnabled = true - ) { - StyledScaffold( - title = "test", - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(72.dp)) - StyledSlider( - value = a.floatValue, - onValueChange = { - a.floatValue = it - }, - valueRange = 0f..2f, - snapPoints = listOf(1f), - snapThreshold = 0.1f, - independent = true, - startIcon = "A", - endIcon = "B", - ) - StyledSlider( - label = "Small label", - description = "This is a somewhat long descriptionRes", - value = a.floatValue, - onValueChange = { - a.floatValue = it - }, - valueRange = 0f..2f, - snapPoints = listOf(1f), - snapThreshold = 0.1f, - independent = true, - startIcon = "A", - endIcon = "B", - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt deleted file mode 100644 index d479da0d2..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt +++ /dev/null @@ -1,317 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import android.content.res.Configuration -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.drawOutline -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.layer.CompositingStrategy -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastCoerceIn -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberCombinedBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.Shadow -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlin.math.abs - -@Composable -fun StyledSwitch( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - val isDarkTheme = isSystemInDarkTheme() - val haptics = LocalHapticFeedback.current - - val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) - val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color( - 0x805B5B5E - ) else Color(0xFFD1D1D6) - - val trackWidth = 64.dp - val trackHeight = 28.dp - val thumbHeight = 24.dp - val thumbWidth = 39.dp - - val backdrop = rememberLayerBackdrop() - val switchBackdrop = rememberLayerBackdrop() - val fraction by remember { - derivedStateOf { if (checked) 1f else 0f } - } - val animatedFraction = remember { Animatable(fraction) } - val trackWidthPx = remember { mutableFloatStateOf(0f) } - val density = LocalDensity.current - val scope = rememberCoroutineScope() - val progressAnimationSpec = spring(0.5f, 300f, 0.001f) - val progressAnimation = remember { Animatable(0f) } - val innerShadowLayer = rememberGraphicsLayer().apply { - compositingStrategy = CompositingStrategy.Offscreen - } - val targetColor = if (checked) onColor else offColor - val animatedTrackColor by animateColorAsState(targetColor) - val totalDrag = remember { mutableFloatStateOf(0f) } - val tapThreshold = 10f - val isFirstComposition = remember { mutableStateOf(true) } - LaunchedEffect(checked) { - if (!isFirstComposition.value) { - if (checked) { - haptics.performHapticFeedback(HapticFeedbackType.ToggleOn) - } else { - haptics.performHapticFeedback(HapticFeedbackType.ToggleOff) - } - coroutineScope { - launch { - val targetFrac = if (checked) 1f else 0f - animatedFraction.animateTo(targetFrac, progressAnimationSpec) - } - if (progressAnimation.value > 0f) return@coroutineScope - launch { - progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing)) - progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing)) - } - } - } - isFirstComposition.value = false - } - - Box( - modifier = Modifier - .width(trackWidth) - .height(trackHeight), - contentAlignment = Alignment.CenterStart - ) { - Box( - modifier = Modifier - .layerBackdrop(switchBackdrop) - .clip(RoundedCornerShape(trackHeight / 2)) - .background(animatedTrackColor) - .width(trackWidth) - .height(trackHeight) - .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } - ) - Box( - modifier = Modifier - .padding(horizontal = 2.dp) - .graphicsLayer { - translationX = animatedFraction.value * (trackWidthPx.floatValue - with(density) { thumbWidth.toPx() + 4.dp.toPx() }) - } - .then(if (enabled) Modifier.draggable( - rememberDraggableState { delta -> - if (trackWidthPx.floatValue > 0f) { - val oldFraction = animatedFraction.value - val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) - scope.launch { - animatedFraction.snapTo(newFraction) - } - totalDrag.floatValue += abs(delta) - val newChecked = newFraction >= 0.5f - if (newChecked != checked) { - onCheckedChange(newChecked) - } - if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) { - haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) - } - } - }, - Orientation.Horizontal, - startDragImmediately = true, - onDragStarted = { - totalDrag.floatValue = 0f - scope.launch { - progressAnimation.animateTo(1f, progressAnimationSpec) - } - }, - onDragStopped = { - scope.launch { - if (totalDrag.floatValue < tapThreshold) { - val newChecked = !checked - onCheckedChange(newChecked) - val snappedFraction = if (newChecked) 1f else 0f - coroutineScope { - launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) } - } - } else { - val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f - onCheckedChange(snappedFraction >= 0.5f) - coroutineScope { - launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) } - } - } - } - } - ) else Modifier) - .drawBackdrop( - rememberCombinedBackdrop(backdrop, switchBackdrop), - { RoundedCornerShape(thumbHeight / 2) }, - highlight = { - val progress = progressAnimation.value - Highlight.Ambient.copy( - alpha = progress - ) - }, - shadow = { - Shadow( - radius = 4f.dp, - color = Color.Black.copy(0.05f) - ) - }, - layerBlock = { - val progress = progressAnimation.value - val scale = lerp(1f, 1.5f, progress) - scaleX = scale - scaleY = scale - }, - onDrawBackdrop = { drawScope -> - drawIntoCanvas { canvas -> - canvas.save() - canvas.drawRect( - left = 0f, - top = 0f, - right = size.width, - bottom = size.height, - paint = Paint().apply { - color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) - } - ) - scale(0.7f) { - drawScope() - } - } - }, - onDrawSurface = { - val progress = progressAnimation.value.fastCoerceIn(0f, 1f) - - val shape = RoundedCornerShape(thumbHeight / 2) - val outline = shape.createOutline(size, layoutDirection, this) - val innerShadowOffset = 4f.dp.toPx() - val innerShadowBlurRadius = 4f.dp.toPx() - - innerShadowLayer.alpha = progress - innerShadowLayer.renderEffect = - BlurEffect( - innerShadowBlurRadius, - innerShadowBlurRadius, - TileMode.Decal - ) - innerShadowLayer.record { - drawOutline(outline, Color.Black.copy(0.2f)) - translate(0f, innerShadowOffset) { - drawOutline( - outline, - Color.Transparent, - blendMode = BlendMode.Clear - ) - } - } - drawLayer(innerShadowLayer) - - drawRect(Color.White.copy(1f - progress)) - }, - effects = { - lens( - refractionHeight = 6f.dp.toPx(), - refractionAmount = size.height / 2f, - depthEffect = true, - chromaticAberration = true - ) - } - ) - .width(thumbWidth) - .height(thumbHeight) - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Composable -fun StyledSwitchPreview() { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) - Box( - modifier = Modifier - .background(backgroundColor) - .width(100.dp) - .height(150.dp), - contentAlignment = Alignment.Center - ) { - val checked = remember { mutableStateOf(true) } - StyledSwitch( - checked = checked.value, - onCheckedChange = { - checked.value = it - }, - enabled = true, - ) -// LaunchedEffect(Unit) { -// delay(1000) -// checked.value = false -// delay(1000) -// checked.value = true -// } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt deleted file mode 100644 index 0d115d8fe..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt +++ /dev/null @@ -1,387 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.sectionHeader -import kotlin.io.encoding.ExperimentalEncodingApi - -@Composable -fun StyledToggle( - title: String? = null, - label: String, - description: String? = null, - checked: Boolean = false, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, - header: Boolean = false -) { - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - Column(modifier = Modifier.padding(vertical = 12.dp)) { - title?.let { - Box( - modifier = Modifier - .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp) - ) { - Text( - text = it, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - } - Column( - modifier = Modifier - .fillMaxWidth() - .background( - if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface, - RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp) - ) - .clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)) - ) { - if (m3eEnabled) { - StyledToggleContent( - label = label, - description = description, - checked = checked, - enabled = enabled, - onCheckedChange = onCheckedChange, - index = 0, - count = 1, - header = header - ) - } else { - StyledToggleContent( - label = label, - checked = checked, - enabled = enabled, - onCheckedChange = onCheckedChange, - index = 0, - count = 1 - ) - } - } - if (description != null && !m3eEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = description, style = TextStyle( - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onBackground.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ), modifier = Modifier.padding(horizontal = 16.dp) - ) - } - } -} - -@Composable -fun StyledListScope.StyledToggle( - label: String, - description: String? = null, - checked: Boolean = false, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, -) { - item { index, count -> - StyledToggleContent( - label = label, - description = description, - checked = checked, - enabled = enabled, - onCheckedChange = onCheckedChange, - index = index, - count = count - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun StyledToggleContent( - label: String, - description: String? = null, - checked: Boolean = false, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, - index: Int, - count: Int, - header: Boolean = false -) { - val currentChecked by rememberUpdatedState(checked) - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val haptics = LocalHapticFeedback.current - val scope = rememberCoroutineScope() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - if (m3eEnabled) { - val defaultShape = when { - count == 1 -> RoundedCornerShape(24.dp) - - index == 0 -> RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomStart = 8.dp, - bottomEnd = 8.dp - ) - - index == count - 1 -> RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp, - bottomStart = 24.dp, - bottomEnd = 24.dp - ) - - else -> RoundedCornerShape(8.dp) - } - Column { - SegmentedListItem( - shapes = ListItemDefaults.shapes().copy( - shape = defaultShape, - pressedShape = RoundedCornerShape(24.dp), - selectedShape = RoundedCornerShape(24.dp), - hoveredShape = RoundedCornerShape(24.dp), - ), - onClick = { onCheckedChange(!currentChecked) }, - trailingContent = { - Switch( - checked = currentChecked, - onCheckedChange = onCheckedChange, - modifier = Modifier.padding(end = if (header) 8.dp else 0.dp), - enabled = enabled - ) - }, - supportingContent = description?.let { - { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp) - .padding(horizontal = if (header) 8.dp else 0.dp), - color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified - ) - } - }, - content = { - Text( - text = label, - style = MaterialTheme.typography.labelMediumEmphasized, - modifier = Modifier - .padding( - top = if (header) 8.dp else 4.dp, - bottom = if (header) 2.dp else 4.dp - ) - .padding(horizontal = if (header) 8.dp else 0.dp), - color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified - ) - }, - enabled = enabled, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.heightIn(min = 64.dp), - colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors() - ) - if (index+1 != count) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } else { - val isPressed = remember { mutableStateOf(false) } - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(28.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(16.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (enabled) { - scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } - onCheckedChange(!currentChecked) - } - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = textColor, - ) - - if (description != null) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = textColor.copy(0.8f) - ) - } - } - - StyledSwitch( - checked = checked, - enabled = enabled, - onCheckedChange = { - if (enabled) { - onCheckedChange(it) - } - } - ) - } - if (index+1 != count) { - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(start = 12.dp,end = 12.dp) - ) - } - } - } -} - -@Preview(name = "List", group = "Apple") -@Composable -fun StyledToggleAppleListPreview() { - val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = false) { - StyledList { - StyledToggle( - label = "Apple Styled List", - description = "This is an example description for the styled toggle.", - checked = checked.value, - onCheckedChange = { checked.value = !checked.value } - ) - } - } -} - -@Preview(name = "Normal", group = "Apple") -@Composable -fun StyledToggleApplePreview() { - val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = false) { - StyledToggle( - label = "Apple", - description = "This is an example description for the styled toggle.", - checked = checked.value, - onCheckedChange = { checked.value = !checked.value } - ) - } -} - -@Preview(name = "List", group = "Apple") -@Composable -fun StyledToggleM3EListPreview() { - val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = true) { - StyledList { - StyledToggle( - label = "Apple Styled List", -// description = "This is an example description for the styled toggle.", - checked = checked.value, - onCheckedChange = { checked.value = !checked.value } - ) - } - } -} - -@Preview(name = "Normal", group = "Material") -@Composable -fun StyledToggleM3EPreview() { - val checked = remember { mutableStateOf(false) } - LibrePodsTheme(m3eEnabled = true) { - StyledToggle( - label = "Material", - description = "This is an example description for the styled toggle.", - checked = checked.value, - onCheckedChange = { checked.value = !checked.value } - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt deleted file mode 100644 index 37bddd8e6..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.components - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.math.sign - -@Composable -fun VerticalVolumeSlider( - displayFraction: Float, - maxVolume: Int, - onVolumeChange: (Int) -> Unit, - initialFraction: Float, - onDragStateChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - baseSliderHeight: Dp = 400.dp, - baseSliderWidth: Dp = 145.dp, - baseCornerRadius: Dp = 45.dp, - maxStretchFactor: Float = 1.15f, - minCompressionFactor: Float = 0.875f, - stretchSensitivity: Float = 1.0f, - compressionSensitivity: Float = 1.0f, - cornerRadiusChangeFactor: Float = 0.2f, - directionalStretchRatio: Float = 0.75f -) { - val trackColor = Color(0x593C3C3E) - val progressColor = Color.White - - var dragFraction by remember { mutableFloatStateOf(initialFraction) } - var isDragging by remember { mutableStateOf(false) } - - var rawDragPosition by remember { mutableFloatStateOf(initialFraction) } - var overscrollAmount by remember { mutableFloatStateOf(0f) } - - val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() } - - val animatedProgress by animateFloatAsState( - targetValue = dragFraction.coerceIn(0f, 1f), - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "ProgressAnimation" - ) - - val animatedOverscroll by animateFloatAsState( - targetValue = overscrollAmount, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMediumLow - ), - label = "OverscrollAnimation" - ) - - val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f) - - val stretchMultiplier = stretchSensitivity - val compressionMultiplier = compressionSensitivity - - val overscrollDirection = sign(animatedOverscroll) - - val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp - - val offsetY = if (abs(animatedOverscroll) > 0.001f) { - val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f) - (-overscrollDirection * asymmetricOffset.value).dp - } else { - 0.dp - } - - val heightStretch = baseSliderHeight + totalStretchAmount - - val widthCompression = baseSliderWidth * max( - minCompressionFactor, - 1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier) - ) - - val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f)) - - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .height(heightStretch) - .width(widthCompression) - .offset(y = offsetY) - .clip(RoundedCornerShape(dynamicCornerRadius)) - .background(trackColor) - .pointerInput(Unit) { - detectTapGestures { offset -> - val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f) - dragFraction = newFraction - rawDragPosition = newFraction - overscrollAmount = 0f - - val newVolume = (newFraction * maxVolume).roundToInt() - onVolumeChange(newVolume) - } - } - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - rawDragPosition -= (delta / baseHeightPx) - - dragFraction = rawDragPosition.coerceIn(0f, 1f) - - overscrollAmount = when { - rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f) - rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f) - else -> 0f - } - - val newVolume = (dragFraction * maxVolume).roundToInt() - onVolumeChange(newVolume) - }, - onDragStarted = { - isDragging = true - dragFraction = displayFraction - rawDragPosition = displayFraction - overscrollAmount = 0f - onDragStateChange(true) - }, - onDragStopped = { - isDragging = false - overscrollAmount = 0f - rawDragPosition = dragFraction - onDragStateChange(false) - } - ), - contentAlignment = Alignment.BottomCenter - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(animatedProgress) - .background(progressColor) - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt deleted file mode 100644 index 14479eb57..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ /dev/null @@ -1,324 +0,0 @@ -package me.kavishdevar.librepods.presentation.navigation - -import androidx.activity.BackEventCompat.Companion.EDGE_LEFT -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.ui.NavDisplay -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.updates.updates -import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen -import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen -import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute -import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen -import me.kavishdevar.librepods.presentation.screens.CallControlScreen -import me.kavishdevar.librepods.presentation.screens.EqualizerRoute -import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen -import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen -import me.kavishdevar.librepods.presentation.screens.HearingAidScreen -import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen -import me.kavishdevar.librepods.presentation.screens.LoadingScreen -import me.kavishdevar.librepods.presentation.screens.LongPress -import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute -import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen -import me.kavishdevar.librepods.presentation.screens.PurchaseScreen -import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen -import me.kavishdevar.librepods.presentation.screens.RenameScreen -import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen -import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen -import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute -import me.kavishdevar.librepods.presentation.screens.VersionScreen -import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel - -@OptIn(ExperimentalHazeMaterialsApi::class) -@Composable -fun AppNavGraph( - showReleaseNotes: Boolean = false, - updatesShown: () -> Unit = {}, - showOnboarding: Boolean = false, - onboardingComplete: () -> Unit = {}, - backStack: SnapshotStateList, - airPodsViewModel: AirPodsViewModel, -) { - val navigate: (Screen) -> Unit = { screen -> - backStack.add(screen) - } - - fun navigateToPurchase() { - navigate(Screen.Purchase) - } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - SharedTransitionLayout { - NavDisplay( - sharedTransitionScope = this, - backStack = backStack, - onBack = { - if (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) - } - }, - entryProvider = { screen -> - when (screen) { - Screen.Onboarding -> - NavEntry(screen) { - OnboardingScreen { - onboardingComplete() - if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings) - backStack.remove(screen) - } - } - Screen.AirPodsSettings -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AirPodsSettingsRoute( - viewModel = airPodsViewModel, - navigateToRename = { navigate(Screen.Rename) }, - navigateToHearingProtection = { navigate(Screen.HearingProtection) }, - navigateToHearingAid = { navigate(Screen.HearingAid) }, - navigateToLeftLongPress = { - navigate( - Screen.LongPress("Left") - ) - }, - navigateToRightLongPress = { - navigate( - Screen.LongPress("Right") - ) - }, - navigateToPurchase = { navigate(Screen.Purchase) }, - navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) }, - navigateToEqualizer = { navigate(Screen.Equalizer) }, - navigateToHeadTracking = { navigate(Screen.HeadTracking) }, - navigateToAccessibility = { navigate(Screen.Accessibility) }, - navigateToVersion = { navigate(Screen.VersionInfo) }, - navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, - navigateToCallControlScreen = { navigate(Screen.CallControl(it)) }, - navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) }, - ) - } - - Screen.Rename -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - RenameScreen(airPodsViewModel) - } - - Screen.AppSettings -> - NavEntry(screen) { - val vm: AppSettingsViewModel = viewModel() - AppSettingsScreen( - viewModel = vm, - navigateToPurchase = ::navigateToPurchase, - navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, - navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, - navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } - ) - } - - Screen.Troubleshooting -> - NavEntry(screen) { - TroubleshootingScreen() - } - - Screen.HeadTracking -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase) - } - - Screen.Accessibility -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AccessibilitySettingsScreen( - viewModel = airPodsViewModel, - navigateToPurchase = ::navigateToPurchase, - navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) } - ) - } - - Screen.TransparencyCustomization -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - TransparencySettingsScreen(airPodsViewModel) - } - - Screen.HearingAid -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingAidScreen( - viewModel = airPodsViewModel, - onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) }, - onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) }, - ) - } - - Screen.HearingAidAdjustments -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingAidAdjustmentsScreen(airPodsViewModel) - } - - Screen.AdaptiveStrength -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase) - } - -// Screen.CameraControl -> -// NavEntry(screen) { -// CameraControlScreen(airPodsViewModel) -// } - - Screen.OpenSourceLicenses -> - NavEntry(screen) { - OpenSourceLicensesScreen() - } - - Screen.UpdateHearingTest -> - NavEntry(screen) { - UpdateHearingTestRoute(airPodsViewModel) - } - - Screen.VersionInfo -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - VersionScreen(airPodsViewModel) - } - - Screen.HearingProtection -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - HearingProtectionScreen( - viewModel = airPodsViewModel, - navigateToPurchase = ::navigateToPurchase - ) - } - - Screen.Purchase -> - NavEntry(screen) { - val vm: PurchaseViewModel = viewModel() - PurchaseScreen(vm, backStack) - } - - Screen.Equalizer -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - EqualizerRoute(airPodsViewModel) - } - - is Screen.LongPress -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - LongPress( - viewModel = airPodsViewModel, - name = screen.bud, - navigateToPurchase = ::navigateToPurchase - ) - } - - is Screen.CallControl -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - CallControlScreen( - viewModel = airPodsViewModel, - action = screen.action, - onCallControlValueChanged = { flipped -> - airPodsViewModel.setControlCommandValue( - AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, - if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf( - 0x00, - 0x03 - ) - ) - } - ) - } - - is Screen.MicrophoneSettings -> - NavEntry(screen) { - if (!airPodsViewModel.isReady) LoadingScreen() - MicrophoneSettingsRoute(viewModel = airPodsViewModel) - } - - is Screen.ReleaseNotes -> - NavEntry(screen) { - ReleaseNotesScreen( - updates = updates, - releaseNotesShown = { - if (showReleaseNotes) { - navigate(Screen.AirPodsSettings) - backStack.remove(screen) - updatesShown() - } else { - backStack.removeAt(backStack.lastIndex) - } - } - ) - } - } - }, - transitionSpec = { - slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 } - }, - popTransitionSpec = { - slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } - }, - predictivePopTransitionSpec = { swipeEdge -> - if (m3eEnabled) { - val enterOffset: (Int) -> Int = - if (swipeEdge == EDGE_LEFT) { - { -it / 6 } - } else { - { it / 6 } - } - - val exitOffset: (Int) -> Int = - if (swipeEdge == EDGE_LEFT) { - { it / 8 } - } else { - { -it / 8 } - } - - fadeIn( - animationSpec = tween(250) - ) + - slideInHorizontally( - initialOffsetX = enterOffset, - animationSpec = tween(250) - ) togetherWith - fadeOut( - targetAlpha = 0.75f, - animationSpec = tween(250) - ) + - scaleOut( - targetScale = 0.85f, - animationSpec = tween(250) - ) + - slideOutHorizontally( - targetOffsetX = exitOffset, - animationSpec = tween(250) - ) - } else { - slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } - } - }, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt deleted file mode 100644 index 2bca355a1..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ /dev/null @@ -1,158 +0,0 @@ -package me.kavishdevar.librepods.presentation.navigation - -import android.util.Log -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.FilledTonalIconToggleButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.kyant.backdrop.backdrops.LayerBackdrop -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.MaterialIcons -import me.kavishdevar.librepods.presentation.components.StyledIconButton -import me.kavishdevar.librepods.presentation.components.StyledScaffold -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@Composable -fun NavigationRoot( - showReleaseNotes: Boolean = false, - updatesShown: () -> Unit = {}, - showOnboarding: Boolean = false, - onboardingComplete: () -> Unit = {}, - airPodsViewModel: AirPodsViewModel -) { - val backStack = remember { - mutableStateListOf( - when { - showOnboarding -> Screen.Onboarding - showReleaseNotes -> Screen.ReleaseNotes - else -> Screen.AirPodsSettings - } - ) - } - - val currentScreen = backStack.last() - - val state by airPodsViewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - val title = when (currentScreen) { - Screen.Onboarding -> "" - Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name) - Screen.Accessibility -> stringResource(R.string.accessibility) - Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio) - Screen.AppSettings -> stringResource(R.string.settings) -// Screen.CameraControl -> stringResource(R.string.camera_control) - Screen.Equalizer -> stringResource(R.string.equalizer) - Screen.HeadTracking -> stringResource(R.string.head_tracking) - Screen.HearingAid -> stringResource(R.string.hearing_aid) - Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) - Screen.HearingProtection -> stringResource(R.string.hearing_protection) - is Screen.LongPress -> currentScreen.bud - Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses) - Screen.Purchase -> stringResource(R.string.unlock_advanced_features) - Screen.Rename -> stringResource(R.string.name) - Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode) - Screen.Troubleshooting -> stringResource(R.string.troubleshooting) - Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test) - Screen.VersionInfo -> stringResource(R.string.version) - is Screen.CallControl -> currentScreen.action - Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode) - Screen.ReleaseNotes -> "" - } - - // is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold - val actionButtons = when (currentScreen) { - Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( - { scaffoldBackdrop -> - if (m3eEnabled) { - FilledTonalIconButton( - onClick = { backStack.add(Screen.AppSettings) }, - modifier = Modifier - .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), - - ) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = "settings", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize) - ) - } - } else { - StyledIconButton( - onClick = { backStack.add(Screen.AppSettings) }, - icon = "􀍟", - backdrop = scaffoldBackdrop - ) - } - } - ) - Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( - { scaffoldBackdrop -> - if (m3eEnabled) { - FilledTonalIconToggleButton( - checked = state.headTrackingActive, - onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() }, - modifier = Modifier - .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), - shape = IconButtonDefaults.mediumRoundShape - ) { - Icon( - imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow, - contentDescription = "Play/Pause", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize) - ) - } - } else { - StyledIconButton( - onClick = { - if (!state.headTrackingActive) { - airPodsViewModel.startHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking started") - } else { - airPodsViewModel.stopHeadTracking() - Log.d("HeadTrackingScreen", "Head tracking stopped") - } - }, - icon = if (state.headTrackingActive) "􀊅" else "􀊃", - backdrop = scaffoldBackdrop - ) - } - } - ) - else -> listOf() - } - - StyledScaffold( - visible = currentScreen.showTopBar, - title = title, - showBackButton = backStack.size > 1, - onNavigateBack = { backStack.removeAt(backStack.lastIndex) }, - actionButtons = actionButtons - ) { - AppNavGraph( - showReleaseNotes = showReleaseNotes, - updatesShown = updatesShown, - showOnboarding = showOnboarding, - onboardingComplete = onboardingComplete, - backStack = backStack, - airPodsViewModel = airPodsViewModel, - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt deleted file mode 100644 index 1a8959f3f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ /dev/null @@ -1,84 +0,0 @@ -package me.kavishdevar.librepods.presentation.navigation - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -@Serializable -sealed interface Screen: NavKey { - val showTopBar: Boolean - get() = true - - @Serializable - data object Onboarding: Screen { - override val showTopBar: Boolean = false - } - - @Serializable - data object AirPodsSettings: Screen - - @Serializable - data object Rename: Screen - - @Serializable - data object AppSettings: Screen - - @Serializable - data object Troubleshooting: Screen - - @Serializable - data object HeadTracking: Screen - - @Serializable - data object Accessibility: Screen - - @Serializable - data object TransparencyCustomization: Screen - - @Serializable - data object HearingAid: Screen - - @Serializable - data object HearingAidAdjustments: Screen - - @Serializable - data object AdaptiveStrength: Screen - -// @Serializable -// data object CameraControl: Screen - - @Serializable - data object OpenSourceLicenses: Screen - - @Serializable - data object UpdateHearingTest: Screen - - @Serializable - data object VersionInfo: Screen - - @Serializable - data object HearingProtection: Screen - - @Serializable - data object Purchase: Screen - - @Serializable - data object Equalizer: Screen - - @Serializable - data class LongPress( - val bud: String - ): Screen - - @Serializable - data class CallControl( - val action: String - ): Screen - - @Serializable - data object MicrophoneSettings: Screen - - @Serializable - data object ReleaseNotes: Screen { - override val showTopBar: Boolean = false - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt deleted file mode 100644 index bf5eff89e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt +++ /dev/null @@ -1,758 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.overlays - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ObjectAnimator -import android.animation.PropertyValuesHolder -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Resources -import android.graphics.PixelFormat -import android.graphics.drawable.GradientDrawable -import android.media.AudioManager -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.Log.e -import android.view.Gravity -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.VelocityTracker -import android.view.View -import android.view.WindowManager -import android.view.animation.AccelerateInterpolator -import android.view.animation.AnticipateOvershootInterpolator -import android.view.animation.DecelerateInterpolator -import android.view.animation.OvershootInterpolator -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import android.widget.VideoView -import androidx.core.net.toUri -import androidx.dynamicanimation.animation.DynamicAnimation -import androidx.dynamicanimation.animation.SpringAnimation -import androidx.dynamicanimation.animation.SpringForce -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.Battery -import me.kavishdevar.librepods.data.BatteryComponent -import me.kavishdevar.librepods.data.BatteryStatus -import me.kavishdevar.librepods.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs - -enum class IslandType { - CONNECTED, - TAKING_OVER, - MOVED_TO_REMOTE, - MOVED_TO_OTHER_DEVICE, -} - -class IslandWindow(private val context: Context) { - private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - @SuppressLint("InflateParams") - private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null) - private var isClosing = false - private var params: WindowManager.LayoutParams? = null - - private var initialY = 0f - private var initialTouchY = 0f - private var lastTouchY = 0f - private var velocityTracker: VelocityTracker? = null - private var isBeingDragged = false - private var autoCloseHandler: Handler? = null - private var autoCloseRunnable: Runnable? = null - private var initialHeight = 0 - private var screenHeight = 0 - private var isDraggingDown = false - private var lastMoveTime = 0L - private var yMovement = 0f - private var dragDistance = 0f - - private var initialConnectedTextY = 0f - private var initialDeviceTextY = 0f - private var initialBatteryViewY = 0f - private var initialVideoViewY = 0f - private var initialTextSeparation = 0f - - private val containerView = FrameLayout(context) - - private lateinit var springAnimation: SpringAnimation - private val flingAnimator = ValueAnimator() - - private val batteryReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra("data", Battery::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableArrayListExtra("data") - } - updateBatteryDisplay(batteryList) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - val isVisible: Boolean - get() = containerView.parent != null && containerView.visibility == View.VISIBLE - - @SuppressLint("SetTextI18n") - private fun updateBatteryDisplay(batteryList: ArrayList?) { - if (batteryList == null || batteryList.isEmpty()) return - - val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } - val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } - - val leftLevel = leftBattery?.level ?: 0 - val rightLevel = rightBattery?.level ?: 0 - leftBattery?.status ?: BatteryStatus.DISCONNECTED - rightBattery?.status ?: BatteryStatus.DISCONNECTED - - val batteryText = islandView.findViewById(R.id.island_battery_text) - val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) - - val displayBatteryLevel = when { - leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel) - leftLevel > 0 -> leftLevel - rightLevel > 0 -> rightLevel - else -> null - } - - if (displayBatteryLevel != null) { - batteryText.text = "$displayBatteryLevel%" - batteryProgressBar.progress = displayBatteryLevel - batteryProgressBar.isIndeterminate = false - } else { - batteryText.text = "?" - batteryProgressBar.progress = 0 - batteryProgressBar.isIndeterminate = false - } - } - - @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag", - "SetTextI18n" - ) - fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { - if (ServiceManager.getService()?.islandOpen == true) return - else ServiceManager.getService()?.islandOpen = true - - val displayMetrics = Resources.getSystem().displayMetrics - val width = (displayMetrics.widthPixels * 0.95).toInt() - screenHeight = displayMetrics.heightPixels - - val batteryList = ServiceManager.getService()?.getBattery() - val batteryText = islandView.findViewById(R.id.island_battery_text) - val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) - - val displayBatteryLevel = if (batteryList != null) { - val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } - val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } - - when { - (leftBattery?.level ?: 0) > 0 && (rightBattery?.level ?: 0) > 0 -> - minOf(leftBattery!!.level, rightBattery!!.level) - (leftBattery?.level ?: 0) > 0 -> leftBattery!!.level - (rightBattery?.level ?: 0) > 0 -> rightBattery!!.level - batteryPercentage > 0 -> batteryPercentage - else -> null - } - } else if (batteryPercentage > 0) { - batteryPercentage - } else { - null - } - - if (displayBatteryLevel != null) { - batteryText.text = "$displayBatteryLevel%" - batteryProgressBar.progress = displayBatteryLevel - } else { - batteryText.text = "?" - batteryProgressBar.progress = 0 - } - - batteryProgressBar.isIndeterminate = false - islandView.findViewById(R.id.island_device_name).text = name - - val actionButton = islandView.findViewById(R.id.island_action_button) - val batteryBg = islandView.findViewById(R.id.island_battery_bg) - if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) { - actionButton.visibility = View.VISIBLE - actionButton.setOnClickListener { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ServiceManager.getService()?.takeOver("reverse") - } - close() - } - batteryText.visibility = View.GONE - batteryProgressBar.visibility = View.GONE - batteryBg.visibility = View.GONE - } else { - actionButton.visibility = View.GONE - batteryText.visibility = View.VISIBLE - batteryProgressBar.visibility = View.VISIBLE - batteryBg.visibility = View.VISIBLE - } - - val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA) - batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(batteryReceiver, batteryIntentFilter) - } - - ServiceManager.getService()?.sendBatteryBroadcast() - - containerView.removeAllViews() - val containerParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - - containerView.addView(islandView, containerParams) - - params = WindowManager.LayoutParams( - width, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, - PixelFormat.TRANSLUCENT - ).apply { - gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - } - - islandView.visibility = View.VISIBLE - containerView.visibility = View.VISIBLE - - containerView.setOnTouchListener { _, event -> - when (event.action) { - MotionEvent.ACTION_DOWN -> { - autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false) - flingAnimator.cancel() - - velocityTracker?.recycle() - velocityTracker = VelocityTracker.obtain() - velocityTracker?.addMovement(event) - - initialY = containerView.translationY - initialTouchY = event.rawY - lastTouchY = event.rawY - initialHeight = islandView.height - isBeingDragged = false - isDraggingDown = false - lastMoveTime = System.currentTimeMillis() - dragDistance = 0f - - captureInitialPositions() - - true - } - MotionEvent.ACTION_MOVE -> { - velocityTracker?.addMovement(event) - val deltaY = event.rawY - initialTouchY - val moveDelta = event.rawY - lastTouchY - dragDistance += abs(moveDelta) - - isDraggingDown = moveDelta > 0 - - val currentTime = System.currentTimeMillis() - val timeDelta = currentTime - lastMoveTime - if (timeDelta > 0) { - yMovement = moveDelta / timeDelta * 10 - } - lastMoveTime = currentTime - - if (abs(deltaY) > 5 || isBeingDragged) { - isBeingDragged = true - - // Cancel auto close timer when dragging starts - autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false) - - val dampedDeltaY = if (deltaY > 0) { - initialY + (deltaY * 0.6f) - } else { - initialY + (deltaY * 0.9f) - } - containerView.translationY = dampedDeltaY - - if (isDraggingDown && deltaY > 0) { - val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f) - applyCustomStretchEffect(stretchAmount) - } - } - - lastTouchY = event.rawY - true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - velocityTracker?.addMovement(event) - velocityTracker?.computeCurrentVelocity(1000) - val yVelocity = velocityTracker?.yVelocity ?: 0f - - if (isBeingDragged) { - val currentTranslationY = containerView.translationY - abs(yVelocity) > 800 - val significantDrag = abs(dragDistance) > 80 - - when { - yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> { - animateDismissWithInertia(yVelocity) - } - yVelocity > 1200 || (isDraggingDown && significantDrag) -> { - animateExpandWithStretch(yVelocity) - } - else -> { - springBackWithInertia(yVelocity) - } - } - } else if (dragDistance < 10) { - resetAutoCloseTimer() - } - - velocityTracker?.recycle() - velocityTracker = null - isBeingDragged = false - true - } - else -> false - } - } - - when (type) { - IslandType.CONNECTED -> { - islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_connected_text) - } - IslandType.TAKING_OVER -> { - islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_taking_over_text) - } - IslandType.MOVED_TO_REMOTE -> { - islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_remote_text) - } - IslandType.MOVED_TO_OTHER_DEVICE -> { - if (otherDeviceName == null || otherDeviceName.isEmpty()) { - e("IslandWindow", "Other device name is null or empty for MOVED_TO_OTHER_DEVICE type") - } - if (reversed) { - islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_reversed_text) - } else { - islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_text, otherDeviceName) - } - } - } - - val videoView = islandView.findViewById(R.id.island_video_view) - val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() - videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) - videoView.setVideoURI(videoUri) - videoView.setOnPreparedListener { mediaPlayer -> - mediaPlayer.isLooping = true - videoView.start() - } - - try { - windowManager.addView(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - - islandView.post { - initialHeight = islandView.height - captureInitialPositions() - } - - springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply { - spring = SpringForce(0f) - .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) - .setStiffness(SpringForce.STIFFNESS_MEDIUM) - } - - val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f) - val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f) - val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f) - ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { - duration = 700 - interpolator = AnticipateOvershootInterpolator() - start() - } - - resetAutoCloseTimer() - } - - private fun captureInitialPositions() { - val connectedText = islandView.findViewById(R.id.island_connected_text) - val deviceText = islandView.findViewById(R.id.island_device_name) - val batteryView = islandView.findViewById(R.id.island_battery_container) - val videoView = islandView.findViewById(R.id.island_video_view) - - connectedText.post { - initialConnectedTextY = connectedText.y - initialDeviceTextY = deviceText.y - initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height) - - if (batteryView != null) initialBatteryViewY = batteryView.y - initialVideoViewY = videoView.y - } - } - - private fun applyCustomStretchEffect(stretchAmount: Float) { - try { - val mainLayout = islandView.findViewById(R.id.island_window_layout) - islandView.findViewById(R.id.island_connected_text) - val deviceText = islandView.findViewById(R.id.island_device_name) - islandView.findViewById(R.id.island_battery_container) - islandView.findViewById(R.id.island_video_view) - - val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f) - val newMinHeight = (initialHeight * stretchFactor).toInt() - mainLayout.minimumHeight = newMinHeight - - val textMarginIncrease = (stretchAmount * 0.8f).toInt() - - val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams - deviceTextParams.topMargin = textMarginIncrease - deviceText.layoutParams = deviceTextParams - - val background = mainLayout.background - if (background is GradientDrawable) { - val cornerRadius = 56f - background.cornerRadius = cornerRadius - } - - if (params != null) { - params!!.height = screenHeight - - val containerParams = containerView.layoutParams - containerParams.height = screenHeight - containerView.layoutParams = containerParams - - try { - windowManager.updateViewLayout(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun resetAutoCloseTimer() { - autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - autoCloseHandler = Handler(Looper.getMainLooper()) - autoCloseRunnable = Runnable { close() } - autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500) - } - - private fun springBackWithInertia(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - springAnimation.setStartVelocity(velocity) - - val baseStiffness = SpringForce.STIFFNESS_MEDIUM - val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f)) - springAnimation.spring = SpringForce(0f) - .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) - .setStiffness(dynamicStiffness) - - resetStretchEffects() - - if (params != null) { - params!!.height = WindowManager.LayoutParams.WRAP_CONTENT - try { - windowManager.updateViewLayout(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - } - - springAnimation.start() - } - - private fun resetStretchEffects() { - try { - val mainLayout = islandView.findViewById(R.id.island_window_layout) - val deviceText = islandView.findViewById(R.id.island_device_name) - - val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight) - heightAnimator.duration = 300 - heightAnimator.interpolator = OvershootInterpolator(1.5f) - heightAnimator.addUpdateListener { animation -> - mainLayout.minimumHeight = animation.animatedValue as Int - } - - val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams - val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0) - textMarginAnimator.duration = 300 - textMarginAnimator.interpolator = OvershootInterpolator(1.5f) - textMarginAnimator.addUpdateListener { animation -> - deviceTextParams.topMargin = animation.animatedValue as Int - deviceText.layoutParams = deviceTextParams - } - - heightAnimator.start() - textMarginAnimator.start() - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun animateDismissWithInertia(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - val baseDistance = -screenHeight - val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f) - val targetDistance = baseDistance * velocityFactor - - val baseDuration = 400L - val velocityDurationFactor = (1500f / (abs(velocity) + 1500f)) - val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L) - - flingAnimator.setFloatValues(containerView.translationY, targetDistance) - flingAnimator.duration = duration - flingAnimator.addUpdateListener { animation -> - containerView.translationY = animation.animatedValue as Float - - val progress = animation.animatedFraction - containerView.scaleX = 1f - (progress * 0.5f) - containerView.scaleY = 1f - (progress * 0.5f) - - containerView.alpha = 1f - progress - } - flingAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - forceClose() - } - }) - - flingAnimator.interpolator = DecelerateInterpolator(1.2f) - flingAnimator.start() - } - - private fun animateExpandWithStretch(velocity: Float) { - springAnimation.cancel() - flingAnimator.cancel() - - val baseDuration = 600L - val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f) - val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L) - - if (params != null) { - params!!.height = screenHeight - try { - windowManager.updateViewLayout(containerView, params) - } catch (e: Exception) { - e.printStackTrace() - } - } - - val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f) - containerAnimator.duration = expandDuration - containerAnimator.interpolator = DecelerateInterpolator(0.8f) - containerAnimator.addUpdateListener { animation -> - containerView.translationY = animation.animatedValue as Float - } - - val stretchAnimator = ValueAnimator.ofFloat(0f, 1f) - stretchAnimator.duration = expandDuration - stretchAnimator.interpolator = OvershootInterpolator(0.5f) - stretchAnimator.addUpdateListener { animation -> - val progress = animation.animatedValue as Float - animateCustomStretch(progress) - } - - val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f) - normalizeAnimator.duration = 300 - normalizeAnimator.startDelay = expandDuration - 150 - normalizeAnimator.interpolator = AccelerateInterpolator(1.2f) - normalizeAnimator.addUpdateListener { animation -> - val progress = animation.animatedValue as Float - containerView.alpha = progress - - if (progress < 0.7f) { - islandView.findViewById(R.id.island_video_view).visibility = View.GONE - } - } - normalizeAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - ServiceManager.getService()?.startMainActivity() - forceClose() - } - }) - - containerAnimator.start() - stretchAnimator.start() - normalizeAnimator.start() - } - - private fun animateCustomStretch(progress: Float) { - try { - val mainLayout = islandView.findViewById(R.id.island_window_layout) - val connectedText = islandView.findViewById(R.id.island_connected_text) - val deviceText = islandView.findViewById(R.id.island_device_name) - - val targetHeight = (screenHeight * 0.7f).toInt() - val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress) - mainLayout.minimumHeight = currentHeight.toInt() - - val mainLayoutParams = mainLayout.layoutParams - mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT - mainLayout.layoutParams = mainLayoutParams - - val targetMargin = (400 * progress).toInt() - val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams - deviceTextParams.topMargin = targetMargin - deviceText.layoutParams = deviceTextParams - - val baseTextSize = 24f - deviceText.textSize = baseTextSize + (progress * 8f) - - val baseSubTextSize = 16f - connectedText.textSize = baseSubTextSize + (progress * 4f) - } catch (e: Exception) { - e.printStackTrace() - } - } - - fun close() { - if (Looper.myLooper() != Looper.getMainLooper()) { - Handler(Looper.getMainLooper()).post { close() } - return - } - try { - if (isClosing) return - isClosing = true - - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { -// e.printStackTrace() - } - - ServiceManager.getService()?.islandOpen = false - autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - - resetStretchEffects() - - val videoView = islandView.findViewById(R.id.island_video_view) - try { - videoView.stopPlayback() - } catch (e: Exception) { - e.printStackTrace() - } - - val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f) - val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f) - val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f) - ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { - duration = 700 - interpolator = AnticipateOvershootInterpolator() - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - cleanupAndRemoveView() - } - }) - start() - } - } catch (e: Exception) { - e.printStackTrace() - // Even if animation fails, ensure we cleanup - cleanupAndRemoveView() - } - } - - private fun cleanupAndRemoveView() { - if (Looper.myLooper() != Looper.getMainLooper()) { - Handler(Looper.getMainLooper()).post { cleanupAndRemoveView() } - return - } - try { - containerView.visibility = View.GONE - } catch (e: Exception) { - e("IslandWindow", "Error setting visibility: $e") - } - try { - if (containerView.parent != null) { - windowManager.removeView(containerView) - } - } catch (e: Exception) { - e("IslandWindow", "Error removing view: $e") - } - isClosing = false - // Make sure all animations are canceled - try { - springAnimation.cancel() - } catch (e: Exception) { - e("IslandWindow", "Error cancelling spring animation $e") - } - try { - flingAnimator.cancel() - } catch (e: Exception) { - e("IslandWindow", "Error cancelling fling animation $e") - } - } - - fun forceClose() { - if (Looper.myLooper() != Looper.getMainLooper()) { - Handler(Looper.getMainLooper()).post { forceClose() } - return - } - try { - if (isClosing) return - isClosing = true - - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - - ServiceManager.getService()?.islandOpen = false - autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) - - // Cancel all ongoing animations - springAnimation.cancel() - flingAnimator.cancel() - - // Immediately remove the view without animations - cleanupAndRemoveView() - } catch (e: Exception) { - e.printStackTrace() - isClosing = false - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt deleted file mode 100644 index 4247ea47a..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - - -package me.kavishdevar.librepods.presentation.overlays - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ObjectAnimator -import android.animation.PropertyValuesHolder -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.PixelFormat -import android.media.AudioManager -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.Gravity -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.WindowManager -import android.view.animation.AccelerateInterpolator -import android.view.animation.DecelerateInterpolator -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.VideoView -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.Battery -import me.kavishdevar.librepods.data.BatteryComponent -import me.kavishdevar.librepods.data.BatteryStatus - -@SuppressLint("InflateParams", "ClickableViewAccessibility") -class PopupWindow( - private val context: Context, - private val onCloseCallback: () -> Unit = {} -) { - private val mView: View - private var isClosing = false - private var autoCloseHandler = Handler(Looper.getMainLooper()) - private var autoCloseRunnable: Runnable? = null - private var batteryUpdateReceiver: BroadcastReceiver? = null - - @Suppress("DEPRECATION") - private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { - height = WindowManager.LayoutParams.WRAP_CONTENT - val displayMetrics = context.resources.displayMetrics - val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density - width = if (screenWidthDp >= 600) { - (400 * displayMetrics.density).toInt() - } else { - WindowManager.LayoutParams.MATCH_PARENT - } - type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - format = PixelFormat.TRANSLUCENT - gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL - dimAmount = 0.3f - flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or - WindowManager.LayoutParams.FLAG_FULLSCREEN or - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_DIM_BEHIND or - WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH - } - - private val mWindowManager: WindowManager - - init { - val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mView = layoutInflater.inflate(R.layout.popup_window, null) - mParams.x = 0 - mParams.y = 0 - - mView.setOnClickListener { - close() - } - - mView.findViewById(R.id.close_button).setOnClickListener { - close() - } - - val ll = mView.findViewById(R.id.linear_layout) - ll.setOnClickListener { - close() - } - - @Suppress("DEPRECATION") - mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_FULLSCREEN or - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - - mView.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - val touchY = event.rawY - val popupTop = mView.top - if (touchY < popupTop) { - close() - true - } else { - false - } - } else { - false - } - } - mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - } - - @SuppressLint("InlinedApi", "SetTextI18s") - fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) { - try { - if (mView.windowToken == null && mView.parent == null && !isClosing) { - mView.findViewById(R.id.name).text = name - - updateBatteryStatus(batteryNotification) - - val vid = mView.findViewById(R.id.video) - vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) - vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) - vid.resolveAdjustedSize(vid.width, vid.height) - vid.start() - vid.setOnCompletionListener { - vid.start() - } - - try { - mWindowManager.addView(mView, mParams) - } catch (e: Exception) { - e.printStackTrace() - } - - val displayMetrics = mView.context.resources.displayMetrics - val screenHeight = displayMetrics.heightPixels - - mView.translationY = screenHeight.toFloat() - mView.alpha = 1f - - val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f) - - ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply { - duration = 500 - interpolator = DecelerateInterpolator() - start() - } - - registerBatteryUpdateReceiver() - - autoCloseRunnable = Runnable { close() } - autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000) - } - } catch (e: Exception) { - Log.e("PopupWindow", "Error opening popup: ${e.message}") - onCloseCallback() - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private fun registerBatteryUpdateReceiver() { - batteryUpdateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.BATTERY_DATA) { - val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra("data", Battery::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableArrayListExtra("data") - } - if (batteryList != null) { - updateBatteryStatusFromList(batteryList) - } - } - } - } - - val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(batteryUpdateReceiver, filter) - } - } - - private fun unregisterBatteryUpdateReceiver() { - batteryUpdateReceiver?.let { - try { - context.unregisterReceiver(it) - batteryUpdateReceiver = null - } catch (e: Exception) { - Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}") - } - } - } - - private fun updateBatteryStatusFromList(batteryList: List) { - val batteryLeftText = mView.findViewById(R.id.left_battery) - val batteryRightText = mView.findViewById(R.id.right_battery) - val batteryCaseText = mView.findViewById(R.id.case_battery) - - batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDC8E ${it.level}%" - } else { - "" - } - } ?: "" - - batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDC8D ${it.level}%" - } else { - "" - } - } ?: "" - - batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "\uDBC3\uDE6C ${it.level}%" - } else { - "" - } - } ?: "" - } - - @SuppressLint("SetTextI18s") - fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) { - val batteryStatus = batteryNotification.getBattery() - updateBatteryStatusFromList(batteryStatus) - } - - fun close() { - try { - if (isClosing) return - isClosing = true - - autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) } - unregisterBatteryUpdateReceiver() - - val vid = mView.findViewById(R.id.video) - vid.stopPlayback() - - ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply { - duration = 500 - interpolator = AccelerateInterpolator() - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - try { - mView.visibility = View.GONE - if (mView.parent != null) { - mWindowManager.removeView(mView) - } - } catch (e: Exception) { - Log.e("PopupWindow", "Error removing view: ${e.message}") - } finally { - isClosing = false - onCloseCallback() - } - } - }) - start() - } - } catch (e: Exception) { - Log.e("PopupWindow", "Error closing popup: ${e.message}") - isClosing = false - onCloseCallback() - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt deleted file mode 100644 index 52e7c1f45..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt +++ /dev/null @@ -1,585 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -// import me.kavishdevar.librepods.utils.RadareOffsetFinder -import android.annotation.SuppressLint -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.Capability -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.time.Duration.Companion.milliseconds - -private var phoneMediaDebounceJob: Job? = null - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class) -@Composable -fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) { - val state by viewModel.uiState.collectAsState() - - val hearingAidEnabled = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( - 1 - ) - ?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( - 0 - )?.toInt() == 1 - - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - if (!state.isPremium) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - -// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } -// val phoneEQEnabled = remember { mutableStateOf(false) } -// val mediaEQEnabled = remember { mutableStateOf(false) } - - val pressSpeedOptions = mapOf( - 0.toByte() to stringResource(R.string.default_option), - 1.toByte() to stringResource(R.string.slower), - 2.toByte() to stringResource(R.string.slowest) - ) - - val selectedPressSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressSpeed by remember { - mutableStateOf( - pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] - ) - } - - val pressAndHoldDurationOptions = mapOf( - 0.toByte() to stringResource(R.string.default_option), - 1.toByte() to stringResource(R.string.slower), - 2.toByte() to stringResource(R.string.slowest) - ) - - val selectedPressAndHoldDurationValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressAndHoldDuration by remember { - mutableStateOf( - pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] - ?: pressAndHoldDurationOptions[0] - ) - } - - val volumeSwipeSpeedOptions = mapOf( - 1.toByte() to stringResource(R.string.default_option), - 2.toByte() to stringResource(R.string.longer), - 3.toByte() to stringResource(R.string.longest) - ) - val selectedVolumeSwipeSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull( - 0 - ) - var selectedVolumeSwipeSpeed by remember { - mutableStateOf( - volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] - ?: volumeSwipeSpeedOptions[1] - ) - } - - val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } - val phoneEQEnabled = remember { mutableStateOf(false) } - val mediaEQEnabled = remember { mutableStateOf(false) } - - LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { - phoneMediaDebounceJob?.cancel() - phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { - delay(150.milliseconds) - try { - val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() - val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() - Log.d( - "AccessibilitySettingsScreen", - "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" - ) - viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) - } catch (e: Exception) { - Log.w( - "AccessibilitySettingsScreen", - "Error sending phone/media EQ: ${e.message}" - ) - } - } - } - - StyledList( - title = stringResource(R.string.press_speed), - description = stringResource(R.string.press_speed_description) - ) { - pressSpeedOptions.forEach { (value, label) -> - StyledListItem( - name = label, - selected = selectedPressSpeed == label, - onClick = { - selectedPressSpeed = label - - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, - value = value - ) - } - ) - } - } - - StyledList( - title = stringResource(R.string.press_and_hold_duration), - description = stringResource(R.string.press_and_hold_duration_description) - ) { - pressAndHoldDurationOptions.forEach { (value, label) -> - StyledListItem( - name = label, - selected = selectedPressAndHoldDuration == label, - onClick = { - selectedPressAndHoldDuration = label - - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, - value = value - ) - } - ) - } - } - - StyledToggle( - title = stringResource(R.string.noise_control), - label = stringResource(R.string.noise_cancellation_single_airpod), - description = stringResource(R.string.noise_cancellation_single_airpod_description), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull( - 0 - ) == 0x01.toByte(), - onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it - ) - }, - enabled = state.isPremium - ) - - if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) { - StyledToggle( - label = stringResource(R.string.loud_sound_reduction), - description = stringResource(R.string.loud_sound_reduction_description), - checked = state.loudSoundReductionEnabled, - onCheckedChange = { - viewModel.setATTCharacteristicValue( - ATTHandles.LOUD_SOUND_REDUCTION, - if (it) byteArrayOf(0x01) else byteArrayOf(0x00) - ) - }, - enabled = state.isPremium - ) - } - - if (!hearingAidEnabled && state.vendorIdHook) { - StyledListItem( - name = stringResource(R.string.customize_transparency_mode), - onClick = navigateToTransparencyCustomization, - enabled = state.isPremium - ) - } - - val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) } - - LaunchedEffect(toneVolumeValue) { - snapshotFlow { - toneVolumeValue.floatValue - } - .debounce(100.milliseconds) - .collect { - viewModel.setControlCommandValue( - AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, - byteArrayOf(it.toInt().toByte(), 0x50) - ) - } - } - - StyledSlider( - label = stringResource(R.string.tone_volume), - description = stringResource(R.string.tone_volume_description), - value = toneVolumeValue.floatValue, - onValueChange = { - toneVolumeValue.floatValue = it - }, - valueRange = 0f..100f, - snapPoints = listOf(75f), - startIcon = "\uDBC0\uDEA1", - endIcon = "\uDBC0\uDEA9", - independent = true, - enabled = state.isPremium - ) - - if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { - val volumeSwipeEnabled = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull( - 0 - )?.toInt() == 0x01 - StyledToggle( - label = stringResource(R.string.volume_control), - description = stringResource(R.string.volume_control_description), - checked = volumeSwipeEnabled, - onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it - ) - }, - enabled = state.isPremium - ) - - StyledList( - title = stringResource(R.string.volume_swipe_speed), - description = stringResource(R.string.volume_swipe_speed_description) - ) { - volumeSwipeSpeedOptions.forEach { (value, label) -> - StyledListItem( - name = label, - selected = selectedVolumeSwipeSpeed == label, - onClick = { - selectedVolumeSwipeSpeed = label - - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, - value = value - ) - } - ) - } - } - } - -// if (!hearingAidEnabled && XposedState.isAvailable) { -// Text( -// text = stringResource(R.string.apply_eq_to), style = TextStyle( -// fontSize = 14.sp, -// fontWeight = FontWeight.Bold, -// color = textColor.copy(alpha = 0.6f), -// fontFamily = FontFamily(Font(R.font.sf_pro)) -// ), modifier = Modifier.padding(8.dp, bottom = 0.dp) -// ) -// Column( -// modifier = Modifier -// .fillMaxWidth() -// .background( -// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), -// RoundedCornerShape(28.dp) -// ) -// .padding(vertical = 0.dp) -// ) { -// val darkModeLocal = isSystemInDarkTheme() -// -// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) -// var phoneBackgroundColor by remember { -// mutableStateOf( -// if (darkModeLocal) Color( -// 0xFF1C1C1E -// ) else Color(0xFFFFFFFF) -// ) -// } -// val phoneAnimatedBackgroundColor by animateColorAsState( -// targetValue = phoneBackgroundColor, -// animationSpec = tween(durationMillis = 500) -// ) -// -// Row( -// modifier = Modifier -// .height(48.dp) -// .fillMaxWidth() -// .background(phoneAnimatedBackgroundColor, phoneShape) -// .pointerInput(Unit) { -// detectTapGestures( -// onPress = { -// phoneBackgroundColor = -// if (darkModeLocal) Color(0x40888888) else Color( -// 0x40D9D9D9 -// ) -// tryAwaitRelease() -// phoneBackgroundColor = -// if (darkModeLocal) Color(0xFF1C1C1E) else Color( -// 0xFFFFFFFF -// ) -// phoneEQEnabled.value = !phoneEQEnabled.value -// }) -// } -// .padding(horizontal = 16.dp), -// verticalAlignment = Alignment.CenterVertically) { -// Text( -// stringResource(R.string.phone), -// fontSize = 16.sp, -// color = textColor, -// fontFamily = FontFamily(Font(R.font.sf_pro)), -// modifier = Modifier.weight(1f) -// ) -// Checkbox( -// checked = phoneEQEnabled.value, -// onCheckedChange = { phoneEQEnabled.value = it }, -// colors = CheckboxDefaults.colors().copy( -// checkedCheckmarkColor = Color(0xFF007AFF), -// uncheckedCheckmarkColor = Color.Transparent, -// checkedBoxColor = Color.Transparent, -// uncheckedBoxColor = Color.Transparent, -// checkedBorderColor = Color.Transparent, -// uncheckedBorderColor = Color.Transparent -// ), -// modifier = Modifier -// .height(24.dp) -// .scale(1.5f) -// ) -// } -// -// HorizontalDivider( -// thickness = 1.dp, color = Color(0x40888888) -// ) -// -// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) -// var mediaBackgroundColor by remember { -// mutableStateOf( -// if (darkModeLocal) Color( -// 0xFF1C1C1E -// ) else Color(0xFFFFFFFF) -// ) -// } -// val mediaAnimatedBackgroundColor by animateColorAsState( -// targetValue = mediaBackgroundColor, -// animationSpec = tween(durationMillis = 500) -// ) -// -// Row( -// modifier = Modifier -// .height(48.dp) -// .fillMaxWidth() -// .background(mediaAnimatedBackgroundColor, mediaShape) -// .pointerInput(Unit) { -// detectTapGestures( -// onPress = { -// mediaBackgroundColor = -// if (darkModeLocal) Color(0x40888888) else Color( -// 0x40D9D9D9 -// ) -// tryAwaitRelease() -// mediaBackgroundColor = -// if (darkModeLocal) Color(0xFF1C1C1E) else Color( -// 0xFFFFFFFF -// ) -// mediaEQEnabled.value = !mediaEQEnabled.value -// }) -// } -// .padding(horizontal = 16.dp), -// verticalAlignment = Alignment.CenterVertically) { -// Text( -// stringResource(R.string.media), -// fontSize = 16.sp, -// color = textColor, -// fontFamily = FontFamily(Font(R.font.sf_pro)), -// modifier = Modifier.weight(1f) -// ) -// Checkbox( -// checked = mediaEQEnabled.value, -// onCheckedChange = { mediaEQEnabled.value = it }, -// colors = CheckboxDefaults.colors().copy( -// checkedCheckmarkColor = Color(0xFF007AFF), -// uncheckedCheckmarkColor = Color.Transparent, -// checkedBoxColor = Color.Transparent, -// uncheckedBoxColor = Color.Transparent, -// checkedBorderColor = Color.Transparent, -// uncheckedBorderColor = Color.Transparent -// ), -// modifier = Modifier -// .height(24.dp) -// .scale(1.5f) -// ) -// } -// } -// -//// EQ Settings. Don't seem to have an effect? -// Column( -// modifier = Modifier -// .fillMaxWidth() -// .background( -// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), -// RoundedCornerShape(28.dp) -// ) -// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally -// ) { -// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) -// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) -// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) -// -// for (i in 0 until 8) { -// val eqPhoneValue = -// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } -// Row( -// horizontalArrangement = Arrangement.SpaceBetween, -// verticalAlignment = Alignment.CenterVertically, -// modifier = Modifier -// .fillMaxWidth() -// .height(38.dp) -// ) { -// Text( -// text = String.format("%.2f", eqPhoneValue.floatValue), -// fontSize = 12.sp, -// color = textColor, -// modifier = Modifier.padding(bottom = 4.dp) -// ) -// -// Slider( -// value = eqPhoneValue.floatValue, -// onValueChange = { newVal -> -// eqPhoneValue.floatValue = newVal -// val newEQ = phoneMediaEQ.value.copyOf() -// newEQ[i] = eqPhoneValue.floatValue -// phoneMediaEQ.value = newEQ -// }, -// valueRange = 0f..100f, -// modifier = Modifier -// .fillMaxWidth(0.9f) -// .height(36.dp), -// colors = SliderDefaults.colors( -// thumbColor = thumbColor, -// activeTrackColor = activeTrackColor, -// inactiveTrackColor = trackColor -// ), -// thumb = { -// Box( -// modifier = Modifier -// .size(24.dp) -// .shadow(4.dp, CircleShape) -// .background(thumbColor, CircleShape) -// ) -// }, -// track = { -// Box( -// modifier = Modifier -// .fillMaxWidth() -// .height(12.dp), -// contentAlignment = Alignment.CenterStart -// ) { -// Box( -// modifier = Modifier -// .fillMaxWidth() -// .height(4.dp) -// .background(trackColor, RoundedCornerShape(4.dp)) -// ) -// Box( -// modifier = Modifier -// .fillMaxWidth(eqPhoneValue.floatValue / 100f) -// .height(4.dp) -// .background( -// activeTrackColor, RoundedCornerShape(4.dp) -// ) -// ) -// } -// }) -// -// Text( -// text = stringResource(R.string.band_label, i + 1), -// fontSize = 12.sp, -// color = textColor, -// modifier = Modifier.padding(top = 4.dp) -// ) -// } -// } -// } -// } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt deleted file mode 100644 index 37a097e4e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@Composable -fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() - val backdrop = rememberLayerBackdrop() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - val sliderValue = remember { - mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f)) - } - var job by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - StyledSlider( - label = stringResource(R.string.customize_adaptive_audio), - value = sliderValue.floatValue, - onValueChange = { - sliderValue.floatValue = it - job?.cancel() - job = scope.launch { - delay(150) - viewModel.setControlCommandValue( - AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH, - byteArrayOf((100 - it).toInt().toByte()) - ) - } - }, - valueRange = 0f..100f, - snapPoints = listOf(0f, 50f, 100f), - startIcon = "􀊥", - endIcon = "􀊩", - independent = true, - description = stringResource(R.string.adaptive_audio_description), - enabled = state.isPremium - ) - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt deleted file mode 100644 index 9583cceab..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ /dev/null @@ -1,1022 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.screens - -// import me.kavishdevar.librepods.utils.RadareOffsetFinder -import android.annotation.SuppressLint -import android.content.Context.MODE_PRIVATE -import android.content.Intent -import android.content.SharedPreferences -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.material3.toPath -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.graphics.shapes.Morph -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.highlight.Highlight -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.delay -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.AirPodsPro3 -import me.kavishdevar.librepods.data.Capability -import me.kavishdevar.librepods.presentation.MaterialIcons -import me.kavishdevar.librepods.presentation.components.AboutCard -import me.kavishdevar.librepods.presentation.components.AudioSettings -import me.kavishdevar.librepods.presentation.components.BatteryView -import me.kavishdevar.librepods.presentation.components.CallControlSettings -import me.kavishdevar.librepods.presentation.components.ConnectionSettings -import me.kavishdevar.librepods.presentation.components.HearingHealthSettings -import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle -import me.kavishdevar.librepods.presentation.components.NoiseControlSettings -import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState -import java.util.concurrent.TimeUnit -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.min -import kotlin.time.Duration.Companion.seconds - -@Composable -fun AirPodsSettingsRoute( - viewModel: AirPodsViewModel, - navigateToRename: () -> Unit, - navigateToHearingProtection: () -> Unit, - navigateToHearingAid: () -> Unit, - navigateToLeftLongPress: () -> Unit, - navigateToRightLongPress: () -> Unit, - navigateToPurchase: () -> Unit, - navigateToAdaptiveStrength: () -> Unit, - navigateToEqualizer: () -> Unit, - navigateToHeadTracking: () -> Unit, - navigateToAccessibility: () -> Unit, - navigateToVersion: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToCallControlScreen: (action: String) -> Unit, - navigateToMicrophoneSettings: () -> Unit -) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = state, - - topPadding = topPadding, - bottomPadding = bottomPadding, - - setControlCommandInt = viewModel::setControlCommandInt, - setControlCommandBoolean = viewModel::setControlCommandBoolean, -// setControlCommandValue = viewModel::setControlCommandValue, - setControlCommandByte = viewModel::setControlCommandByte, - - setATTCharacteristicValue = viewModel::setATTCharacteristicValue, - - onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, - onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, - setDynamicEndOfCharge = viewModel::setDynamicEndOfCharge, - setOffListeningMode = viewModel::setOffListeningMode, - disconnect = viewModel::disconnect, - - navigateToRename = navigateToRename, - navigateToHearingProtection = navigateToHearingProtection, - navigateToHearingAid = navigateToHearingAid, - navigateToLeftLongPress = navigateToLeftLongPress, - navigateToRightLongPress = navigateToRightLongPress, - navigateToPurchase = navigateToPurchase, - navigateToAdaptiveStrength = navigateToAdaptiveStrength, - navigateToEqualizer = navigateToEqualizer, - navigateToHeadTracking = navigateToHeadTracking, - navigateToAccessibility = navigateToAccessibility, - navigateToVersion = navigateToVersion, - navigateToTroubleshooting = navigateToTroubleshooting, - navigateToCallControlScreen = navigateToCallControlScreen, - navigateToMicrophoneSettings = navigateToMicrophoneSettings, - - activateDemoMode = viewModel::activateDemoMode, - reconnectFromSavedMac = viewModel::reconnectFromSavedMac - ) - } -} - - @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) -@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") -@Composable -fun AirPodsSettingsScreen( - state: AirPodsUiState, - - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - - setControlCommandInt: (AACPManager.Companion.ControlCommandIdentifiers, Int) -> Unit, - setControlCommandBoolean: (AACPManager.Companion.ControlCommandIdentifiers, Boolean) -> Unit, -// setControlCommandValue: (AACPManager.Companion.ControlCommandIdentifiers, ByteArray) -> Unit, - setControlCommandByte: (AACPManager.Companion.ControlCommandIdentifiers, Byte) -> Unit, - setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit, - - onAutomaticEarDetectionChanged: (Boolean) -> Unit, - onAutomaticConnectionChanged: (Boolean) -> Unit, - setDynamicEndOfCharge: (Boolean) -> Unit, - setOffListeningMode: (Boolean) -> Unit, - disconnect: () -> Unit, - - navigateToRename: () -> Unit, - navigateToHearingProtection: () -> Unit, - navigateToHearingAid: () -> Unit, - navigateToLeftLongPress: () -> Unit, - navigateToRightLongPress: () -> Unit, - navigateToPurchase: () -> Unit, - navigateToAdaptiveStrength: () -> Unit, - navigateToEqualizer: () -> Unit, - navigateToHeadTracking: () -> Unit, - navigateToAccessibility: () -> Unit, - navigateToVersion: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToCallControlScreen: (action: String) -> Unit, - navigateToMicrophoneSettings: () -> Unit, - - activateDemoMode: () -> Unit, - reconnectFromSavedMac: () -> Unit, -) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) - var deviceName by remember { - mutableStateOf( - TextFieldValue( - sharedPreferences.getString("name", state.deviceName).toString() - ) - ) - } - - val nameChangeListener = remember { - SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == "name") { - deviceName = - TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString()) - } - } - } - - DisposableEffect(Unit) { - sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener) - onDispose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener) - } - } - - if (state.isLocallyConnected) { - val capabilities = state.capabilities - - LazyColumn( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } - item(key = "play_update_banner") { - if (state.timeUntilFOSSPremiumExpiry > 0L) { - val context = LocalContext.current - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) - }) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, - maxOf( - 1, - TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry) - .toInt() - ) - ), modifier = Modifier.padding(16.dp), style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - } - - item(key = "battery") { - BatteryView( - batteryList = state.battery, - budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_buds, - caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case - ) - } - item(key = "spacer_battery") { - Spacer(modifier = Modifier.height(32.dp)) - } - - item(key = "name") { - StyledListItem( - name = stringResource(R.string.name), - description = deviceName.text, - onClick = navigateToRename, - ) - } - - val hasHearingAidCapability = - state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true - val hasPPECapability = - state.instance?.model?.capabilities?.contains(Capability.PPE) == true - - if (hasHearingAidCapability || hasPPECapability) { - if (hasPPECapability || state.vendorIdHook) { - item(key = "spacer_hearing_health") { - Spacer(modifier = Modifier.height(24.dp)) - } - } - item(key = "hearing_health") { - HearingHealthSettings( - hasPPECapability = hasPPECapability, - hasHearingAidCapability = hasHearingAidCapability, - vendorIdHook = state.vendorIdHook, - navigateToHearingProtection = navigateToHearingProtection, - navigateToHearingAid = navigateToHearingAid - ) - } - } - - if (capabilities.contains(Capability.LISTENING_MODE)) { - item(key = "spacer_noise") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "noise_control") { - NoiseControlSettings( - showOffListeningMode = state.offListeningMode, - noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull( - 0 - )?.toInt() ?: 3, - onNoiseControlModeChanged = { - setControlCommandInt( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE, it - ) - }, - ) - } - } - - if (capabilities.contains(Capability.STEM_CONFIG)) { - item(key = "spacer_press_hold") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "press_hold") { - PressAndHoldSettings( - leftAction = state.leftAction, - rightAction = state.rightAction, - navigateToLeftLongPress = navigateToLeftLongPress, - navigateToRightLongPress = navigateToRightLongPress - ) - } - } - - item(key = "spacer_call") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "call_control") { - val bytes = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( - 2 - )?.toByteArray() ?: byteArrayOf(0x00, 0x00) - val flipped = try { - bytes[1] == 0x02.toByte() - } catch (_: Exception) { - false - } - CallControlSettings( - flipped = flipped, - navigateToCallControlScreen = navigateToCallControlScreen - ) - } - -// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) { -// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } -// item(key = "camera_control") { -// StyledListItem( -// to = "camera_control", -// name = stringResource(R.string.camera_remote), -// descriptionRes = stringResource(R.string.camera_control_description), -// titleRes = stringResource(R.string.camera_control), -// navController = navController -// ) -// } -// } - - item(key = "upgrade_button") { - if (!state.isPremium) { - Spacer(modifier = Modifier.height(28.dp)) - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(8.dp)) - } - } - - item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "audio") { - val model = state.instance?.model ?: AirPodsPro3() - val adaptiveVolumeCapability = - model.capabilities.contains(Capability.ADAPTIVE_VOLUME) - val conversationalAwarenessCapability = - model.capabilities.contains(Capability.CONVERSATION_AWARENESS) - val loudSoundReductionCapability = - model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) - val adaptiveAudioCapability = - model.capabilities.contains(Capability.ADAPTIVE_VOLUME) - - val adaptiveVolumeChecked = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull( - 0 - ) == 0x01.toByte() - val conversationalAwarenessChecked = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull( - 0 - ) == 0x01.toByte() - - AudioSettings( - adaptiveVolumeCapability = adaptiveVolumeCapability, - conversationalAwarenessCapability = conversationalAwarenessCapability, - loudSoundReductionCapability = loudSoundReductionCapability, - adaptiveAudioCapability = adaptiveAudioCapability, - customEqCapability = true, - adaptiveVolumeChecked = adaptiveVolumeChecked, - onAdaptiveVolumeCheckedChange = { checked -> - setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, - checked - ) - }, - conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium, - onConversationalAwarenessCheckedChange = { checked -> - setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, - checked - ) - }, - loudSoundReductionChecked = state.loudSoundReductionEnabled, - onLoudSoundReductionCheckedChange = { checked -> - setATTCharacteristicValue( - ATTHandles.LOUD_SOUND_REDUCTION, - byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte()) - ) - }, - navigateToAdaptiveStrength = navigateToAdaptiveStrength, - navigateToEqualizer = navigateToEqualizer, - vendorIdHook = state.vendorIdHook, - isPremium = state.isPremium - ) - } - - item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "connection") { - ConnectionSettings( - automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, - onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, - automaticConnectionEnabled = state.automaticConnectionEnabled, - onAutomaticConnectionChanged = onAutomaticConnectionChanged - ) - } - - item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "microphone") { - val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE - - val selectedModeText = when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) { - 0x00.toByte() -> stringResource(R.string.microphone_automatic) - 0x01.toByte() -> stringResource(R.string.microphone_always_right) - 0x02.toByte() -> stringResource(R.string.microphone_always_left) - else -> stringResource(R.string.microphone_automatic) - } - - StyledListItem( - name = stringResource(R.string.microphone_mode), - description = selectedModeText, - onClick = navigateToMicrophoneSettings - ) - } - - if (capabilities.contains(Capability.SLEEP_DETECTION)) { - item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "sleep_detection") { - val id = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG - StyledToggle( - label = stringResource(R.string.sleep_detection), - checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = { setControlCommandBoolean(id, it) }, - enabled = state.isPremium - ) - } - } - - if (capabilities.contains(Capability.HEAD_GESTURES)) { - item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "head_tracking") { - StyledListItem( - name = stringResource(R.string.head_gestures), - description = if (sharedPreferences.getBoolean( - "head_gestures", false - ) - ) stringResource(R.string.on) else stringResource(R.string.off), - onClick = navigateToHeadTracking - ) - } - } - - item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "dynamic_end_of_charge") { - StyledToggle( - label = stringResource(R.string.optimized_charging), - description = stringResource(R.string.optimized_charging_description), - checked = state.dynamicEndOfCharge, - onCheckedChange = setDynamicEndOfCharge - ) - } - - item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "accessibility") { - StyledListItem( - name = stringResource(R.string.accessibility), onClick = navigateToAccessibility - ) - } - - if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { - item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } - item(key = "off_listening") { - val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION - StyledToggle( - label = stringResource(R.string.off_listening_mode), - description = stringResource(R.string.off_listening_mode_description), - checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = setOffListeningMode - ) - } - } - - item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } - item(key = "about") { - AboutCard( - modelName = state.modelName, - actualModel = state.actualModel, - serialNumbers = state.serialNumbers, - version = state.version3, - navigateToVersion = navigateToVersion - ) - } - - item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) } - item(key = "disconnect_button") { - StyledButton( - onClick = disconnect, - backdrop = rememberLayerBackdrop(), - isInteractive = false, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp) - ) { - Text( - text = stringResource(R.string.disconnect), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - } - } - -// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } -// item(key = "debug") { StyledListItem("debug", "Debug", navController) } - - item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } - } - } else { - val backdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .drawBackdrop( - backdrop = rememberLayerBackdrop(), - exportedBackdrop = backdrop, - shape = { RoundedCornerShape(0.dp) }, - highlight = { - Highlight.Ambient.copy(alpha = 0f) - }, - effects = {} - ) - .fillMaxSize() - .padding(start = 8.dp, end = 8.dp, bottom = bottomPadding), - contentAlignment = Alignment.Center - ) { - val tapCount = remember { mutableIntStateOf(0) } - val lastTapTime = remember { mutableLongStateOf(0L) } - - var reconnecting by remember { mutableStateOf(false) } - - LaunchedEffect(reconnecting) { - if (reconnecting) { - delay(5.seconds) - reconnecting = false - } - } - - when (LocalDesignSystem.current) { - DesignSystem.Material -> { - val polygons = remember { - listOf( - MaterialShapes.Cookie9Sided, - MaterialShapes.Clover4Leaf, - MaterialShapes.SoftBurst, - MaterialShapes.Sunny, - MaterialShapes.Pentagon, - MaterialShapes.Cookie4Sided, - MaterialShapes.Oval, - ) - } - - val morphs = remember { - buildList { - for (i in polygons.indices) { - add( - Morph( - polygons[i].normalized(), - polygons[(i + 1) % polygons.size].normalized() - ) - ) - } - } - } - - var currentMorphIndex by remember { mutableIntStateOf(0) } - - val morphProgress = remember { Animatable(0f) } - - LaunchedEffect(reconnecting) { - if (!reconnecting) { - currentMorphIndex = 0 - morphProgress.snapTo(0f) - return@LaunchedEffect - } - - while (reconnecting) { - morphProgress.snapTo(0f) - - morphProgress.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = 650, - easing = FastOutSlowInEasing - ) - ) - - currentMorphIndex = (currentMorphIndex + 1) % morphs.size - } - } - - val path = remember { Path() } - val scaleMatrix = remember { Matrix() } - - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - activateDemoMode() - } - ) - } - ) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 32.dp) - .pointerInput(Unit) { - detectTapGestures( - onTap = { - val now = System.currentTimeMillis() - - if (now - lastTapTime.longValue > 400) { - tapCount.intValue = 0 - } - - tapCount.intValue++ - lastTapTime.longValue = now - - if (tapCount.intValue >= 5) { - tapCount.intValue = 0 - activateDemoMode() - } - } - ) - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val primaryContainerColor = MaterialTheme.colorScheme.tertiaryContainer - val secondaryContainerColor = MaterialTheme.colorScheme.secondaryContainer - - val animatedShapeColor by animateColorAsState(if (reconnecting) primaryContainerColor else secondaryContainerColor) - - Box( - modifier = Modifier - .size(240.dp) - .background( - MaterialTheme.colorScheme.surfaceBright, - CircleShape - ) - .clickable( - interactionSource = null, - indication = ripple( - bounded = false, - radius = 120.dp - ), - enabled = !reconnecting, - onClick = {} - ) - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (!reconnecting) { - currentMorphIndex = 1 - reconnecting = true - reconnectFromSavedMac() - } - }, - onPress = { - if (!reconnecting) { - morphProgress.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) - tryAwaitRelease() - morphProgress.animateTo( - targetValue = 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) - } - } - ) - } - .drawWithContent { - val activeMorph = morphs[currentMorphIndex] - - val shapePath = activeMorph.toPath( - progress = morphProgress.value, - path = path - ) - - val bounds = shapePath.getBounds() - - val scale = min(size.width/bounds.width, size.height/bounds.height) * 0.8f - - scaleMatrix.reset() - - scaleMatrix.scale(x = scale, y = scale) - - shapePath.transform(scaleMatrix) - - shapePath.translate(size.center - shapePath.getBounds().center) - - drawPath( - path = shapePath, - color = animatedShapeColor - ) - - drawContent() - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (reconnecting) MaterialIcons.bluetooth_searching else MaterialIcons.headset_off, - contentDescription = null, - modifier = Modifier.size(84.dp), - tint = if (reconnecting) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer - ) - } - - Spacer(Modifier.height(40.dp)) - - Text( - text = if (reconnecting) stringResource(R.string.reconnecting) else stringResource(R.string.tap_to_reconnect), - style = MaterialTheme.typography.labelSmallEmphasized, - color = MaterialTheme.colorScheme.primary - ) - } - - if (!BuildConfig.PLAY_BUILD) { - OutlinedButton( - onClick = navigateToTroubleshooting, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(24.dp) - ) { - Text( - stringResource( - R.string.troubleshooting - ), - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - - DesignSystem.Apple -> { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onTap = { - val now = System.currentTimeMillis() - - if (now - lastTapTime.longValue > 400) { - tapCount.intValue = 0 - } - - tapCount.intValue++ - lastTapTime.longValue = now - - if (tapCount.intValue >= 5) { - tapCount.intValue = 0 - activateDemoMode() - } - }) - }) { - Text( - text = stringResource(R.string.airpods_not_connected), - style = MaterialTheme.typography.displaySmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.airpods_not_connected_description), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (state.connectionSuccessful) { - StyledButton( - onClick = { reconnectFromSavedMac(); reconnecting = true }, - backdrop = backdrop, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .widthIn(max = 200.dp), - enabled = !reconnecting - ) { - Text( - text = stringResource(R.string.reconnect_to_last_device), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - if (!BuildConfig.PLAY_BUILD) { - StyledButton( - onClick = navigateToTroubleshooting, - backdrop = backdrop, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp) - .widthIn(max = 200.dp), - materialButtonStyle = MaterialButtonStyle.Outlined, - ) { - Text( - text = stringResource(R.string.troubleshooting), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - } -} - -@Preview(name = "Apple") -@Composable -fun AirPodsSettingsScreenPreviewApple() { - LibrePodsTheme( - m3eEnabled = false - ) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = demoState, - - setControlCommandInt = { _, _ -> }, - setControlCommandBoolean = { _, _ -> }, - setControlCommandByte = { _, _ -> }, - setATTCharacteristicValue = { _, _ -> }, - - onAutomaticEarDetectionChanged = {}, - onAutomaticConnectionChanged = {}, - setDynamicEndOfCharge = {}, - setOffListeningMode = {}, - disconnect = {}, - - navigateToRename = {}, - navigateToHearingProtection = {}, - navigateToHearingAid = {}, - navigateToLeftLongPress = {}, - navigateToRightLongPress = {}, - navigateToPurchase = {}, - navigateToAdaptiveStrength = {}, - navigateToEqualizer = {}, - navigateToHeadTracking = {}, - navigateToAccessibility = {}, - navigateToVersion = {}, - navigateToTroubleshooting = {}, - navigateToCallControlScreen = {}, - navigateToMicrophoneSettings = {}, - - activateDemoMode = {}, - reconnectFromSavedMac = {} - ) - } - } -} - - -@Preview(name = "Material") -@Composable -fun AirPodsSettingsScreenPreviewMaterial() { - LibrePodsTheme( - m3eEnabled = true - ) { - Box ( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - AirPodsSettingsScreen( - state = demoState, - - setControlCommandInt = { _, _ -> }, - setControlCommandBoolean = { _, _ -> }, - setControlCommandByte = { _, _ -> }, - setATTCharacteristicValue = { _, _ -> }, - - onAutomaticEarDetectionChanged = {}, - onAutomaticConnectionChanged = {}, - setDynamicEndOfCharge = {}, - setOffListeningMode = {}, - disconnect = {}, - - navigateToRename = {}, - navigateToHearingProtection = {}, - navigateToHearingAid = {}, - navigateToLeftLongPress = {}, - navigateToRightLongPress = {}, - navigateToPurchase = {}, - navigateToAdaptiveStrength = {}, - navigateToEqualizer = {}, - navigateToHeadTracking = {}, - navigateToAccessibility = {}, - navigateToVersion = {}, - navigateToTroubleshooting = {}, - navigateToCallControlScreen = {}, - navigateToMicrophoneSettings = {}, - - activateDemoMode = {}, - reconnectFromSavedMac = {} - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt deleted file mode 100644 index 06436561d..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ /dev/null @@ -1,613 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.AppInfoCard -import me.kavishdevar.librepods.presentation.components.DeviceInfoCard -import me.kavishdevar.librepods.presentation.components.StyledBottomSheet -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledIconButton -import me.kavishdevar.librepods.presentation.components.StyledInputField -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.theme.MaterialTypography -import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import me.kavishdevar.librepods.utils.XposedState -import java.util.concurrent.TimeUnit - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppSettingsScreen( - viewModel: AppSettingsViewModel = viewModel(), - navigateToPurchase: () -> Unit, - navigateToTroubleshooting: () -> Unit, - navigateToOpenSourceLicenses: () -> Unit, - navigateToReleaseNotesScreen: () -> Unit -) { - val context = LocalContext.current - val scrollState = rememberScrollState() - val state by viewModel.uiState.collectAsState() - - val backdrop = rememberLayerBackdrop() - - val contactBottomSheet = remember { mutableStateOf(false) } - val subjectState = remember { TextFieldState() } - val descriptionState = remember { TextFieldState() } - val subjectFocusRequester = remember { FocusRequester() } - val descriptionFocusRequester = remember { FocusRequester() } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .layerBackdrop(backdrop) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - val isDarkTheme = isSystemInDarkTheme() - - if (!state.isPremium && state.connectionSuccessful) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - if (state.timeUntilFOSSPremiumExpiry > 0L) { - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) - } - ) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt()) - ), - modifier = Modifier - .padding(16.dp), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } - - StyledToggle( - title = stringResource(R.string.appearance), - label = stringResource(R.string.use_material3e), - checked = state.m3eEnabled, - onCheckedChange = viewModel::setm3eEnabled, - enabled = state.isPremium - ) - - if (state.connectionSuccessful) { - StyledToggle( - title = stringResource(R.string.widget), - label = stringResource(R.string.show_phone_battery_in_widget), - description = stringResource(R.string.show_phone_battery_in_widget_description), - checked = state.showPhoneBatteryInWidget, - onCheckedChange = viewModel::setShowPhoneBatteryInWidget, - enabled = state.isPremium - ) - - StyledList(title = stringResource(R.string.popup_animations)) { - StyledToggle( - label = stringResource(R.string.show_bottom_sheet_popup), - description = stringResource(R.string.show_bottom_sheet_popup_description), - checked = state.showBottomSheetPopup, - onCheckedChange = viewModel::setShowBottomSheetPopup, - ) - - StyledToggle( - label = stringResource(R.string.show_island_popup), - description = stringResource(R.string.show_island_popup_description), - checked = state.showIslandPopup, - onCheckedChange = viewModel::setShowIslandPopup, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - StyledList (title = stringResource(R.string.conversational_awareness)) { - StyledToggle( - label = stringResource(R.string.conversational_awareness_pause_music), - description = stringResource(R.string.conversational_awareness_pause_music_description), - checked = state.conversationalAwarenessPauseMusicEnabled, - onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, - enabled = state.isPremium - ) - - StyledToggle( - label = stringResource(R.string.relative_conversational_awareness_volume), - description = stringResource(R.string.relative_conversational_awareness_volume_description), - checked = state.relativeConversationalAwarenessVolumeEnabled, - onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, - enabled = state.isPremium, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - val conversationalAwarenessVolume = state.conversationalAwarenessVolume - LaunchedEffect(conversationalAwarenessVolume) { - viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) - } - - StyledSlider( - label = stringResource(R.string.conversational_awareness_volume), - value = conversationalAwarenessVolume, - valueRange = 10f..85f, - snapPoints = listOf(44f), - startLabel = "10%", - endLabel = "85%", - onValueChange = { newValue -> - viewModel.setConversationalAwarenessVolume( - newValue - ) - }, - independent = true, - enabled = state.isPremium - ) - -// if (!BuildConfig.PLAY_BUILD) { -// Spacer(modifier = Modifier.height(16.dp)) -// -// StyledListItem( -// to = "", -// titleRes = stringResource(R.string.camera_control), -// name = stringResource(R.string.set_custom_camera_package), -// navController = navController, -// onClick = { -// if (state.isPremium) viewModel.setShowCameraDialog(true) -// }, -// independent = true, -// descriptionRes = stringResource(R.string.camera_control_app_description) -// ) -// } - - Spacer(modifier = Modifier.height(16.dp)) - if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - StyledToggle( - title = stringResource(R.string.ear_detection), - label = stringResource(R.string.disconnect_when_not_wearing), - description = stringResource(R.string.disconnect_when_not_wearing_description), - checked = state.disconnectWhenNotWearing, - onCheckedChange = viewModel::setDisconnectWhenNotWearing, - enabled = state.isPremium - ) - } - - StyledList(title = stringResource(R.string.takeover_airpods_state)) { - StyledToggle( - label = stringResource(R.string.takeover_disconnected), - description = stringResource(R.string.takeover_disconnected_desc), - checked = state.takeoverWhenDisconnected, - onCheckedChange = viewModel::setTakeoverWhenDisconnected, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_idle), - description = stringResource(R.string.takeover_idle_desc), - checked = state.takeoverWhenIdle, - onCheckedChange = viewModel::setTakeoverWhenIdle, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_music), - description = stringResource(R.string.takeover_music_desc), - checked = state.takeoverWhenMusic, - onCheckedChange = viewModel::setTakeoverWhenMusic, - enabled = state.isPremium - ) - - StyledToggle( - label = stringResource(R.string.takeover_call), - description = stringResource(R.string.takeover_call_desc), - checked = state.takeoverWhenCall, - onCheckedChange = viewModel::setTakeoverWhenCall, - enabled = state.isPremium - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - StyledList(title = stringResource(R.string.takeover_phone_state)) { - StyledToggle( - label = stringResource(R.string.takeover_ringing_call), - description = stringResource(R.string.takeover_ringing_call_desc), - checked = state.takeoverWhenRingingCall, - onCheckedChange = viewModel::setTakeoverWhenRingingCall, - enabled = state.isPremium - ) - StyledToggle( - label = stringResource(R.string.takeover_media_start), - description = stringResource(R.string.takeover_media_start_desc), - checked = state.takeoverWhenMediaStart, - onCheckedChange = viewModel::setTakeoverWhenMediaStart, - enabled = state.isPremium - ) - } - - StyledToggle( - title = stringResource(R.string.advanced_options), // shouldn't be here, but okay - label = stringResource(R.string.use_alternate_head_tracking_packets), - description = stringResource(R.string.use_alternate_head_tracking_packets_description), - checked = state.useAlternateHeadTrackingPackets, - onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, - enabled = state.isPremium - ) - Spacer(modifier = Modifier.height(16.dp)) - } else { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - .padding(top = 16.dp, bottom = 2.dp) - ) { - Text( - text = stringResource(R.string.customizations_unavailable), - style = MaterialTypography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - ) - } - } - - if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { - val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) - StyledToggle( - label = stringResource(R.string.act_as_an_apple_device) + " (${ - stringResource( - R.string.requires_xposed - ) - })", - description = stringResource(R.string.act_as_an_apple_device_description), - checked = state.vendorIdHook, - onCheckedChange = { enabled -> - Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() - viewModel.setVendorIdHook(enabled) - } - ) - } - - if (!BuildConfig.PLAY_BUILD) { - Spacer(modifier = Modifier.height(16.dp)) - StyledList { - StyledListItem( - name = stringResource(R.string.troubleshooting), - onClick = navigateToTroubleshooting, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledList(title = stringResource(R.string.contact)) { - StyledListItem( - name = stringResource(R.string.email), - onClick = { contactBottomSheet.value = true }, - ) - - StyledListItem( - name = stringResource(R.string.discord), - onClick = { - val intent = - Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) - context.startActivity(intent) - }, - ) - - StyledListItem( - name = stringResource(R.string.github_issues), - onClick = { - val appVersion = - Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") - val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}") - val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})") - val appSource = Uri.encode( - when { - BuildConfig.PLAY_BUILD -> "Play" - else -> "GitHub" - } - ) - val url = "https://github.com/kavishdevar/librepods/issues/new" + - "?template=01-bug-report-android.yml" + - "&app-source=$appSource" + - "&app-version=$appVersion" + - "&device=$device" + - "&android-version=$androidVersion" - - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - context.startActivity(intent) - }, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - DeviceInfoCard() - Spacer(modifier = Modifier.height(16.dp)) - AppInfoCard(navigateToReleaseNotesScreen) - - Spacer(modifier = Modifier.height(16.dp)) - - StyledListItem( - name = stringResource(R.string.open_source_licenses), - onClick = navigateToOpenSourceLicenses, - ) - - Spacer(modifier = Modifier.height(bottomPadding)) - - if (state.showCameraDialog) { - AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { - Text( - stringResource(R.string.set_custom_camera_package), - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - }, text = { - Column { - Text( - stringResource(R.string.enter_custom_camera_package), - fontFamily = FontFamily(Font(R.font.sf_pro)), - modifier = Modifier.padding(bottom = 8.dp) - ) - - OutlinedTextField( - value = state.cameraPackageValue, - onValueChange = { - viewModel.setCameraPackageValue(it) - viewModel.setCameraPackageError(null) - }, - modifier = Modifier.fillMaxWidth(), - isError = state.cameraPackageError != null, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Ascii, - capitalization = KeyboardCapitalization.None - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color( - 0xFF3C6DF5 - ), - unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray - ), - supportingText = { - if (state.cameraPackageError != null) { - Text( - state.cameraPackageError ?: "", - color = MaterialTheme.colorScheme.error - ) - } - }, - label = { Text(stringResource(R.string.custom_camera_package)) }) - } - }, confirmButton = { - val successText = stringResource(R.string.custom_camera_package_set_success) - TextButton( - onClick = { - viewModel.saveCameraPackage() - Toast.makeText(context, successText, Toast.LENGTH_SHORT).show() - }) { - Text( - "Save", - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - } - }, dismissButton = { - TextButton( - onClick = { viewModel.setShowCameraDialog(false) }) { - Text( - "Cancel", - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium - ) - } - }) - } - } - - StyledBottomSheet( - visible = contactBottomSheet.value, - onDismiss = { contactBottomSheet.value = false }, - backdrop = backdrop - ) { innerBackdrop, progress -> - val animatedPadding = lerp(16.dp, 2.dp, progress) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = animatedPadding) - .padding(bottom = 16.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - StyledIconButton( - icon = "\uDBC0\uDD84", - backdrop = innerBackdrop, - onClick = { contactBottomSheet.value = false } - ) - Text ( - text = stringResource(R.string.describe_your_issue), - style = TextStyle( - fontSize = 18.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - color = if (isSystemInDarkTheme()) Color.White else Color.Black - ) - ) - StyledIconButton( - icon = "\uDBC0\uDE1F", - backdrop = innerBackdrop, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF), - iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray, - enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(), - onClick = { - contactBottomSheet.value = false - val intent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}") - putExtra( - Intent.EXTRA_TEXT, - "${descriptionState.text}" + - "\n\n----------" + - "\nPhone details:" + - "\nMANUFACTURER: ${Build.MANUFACTURER}" + - "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + - "\nDISPLAY_VERSION: ${Build.DISPLAY}" + - "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" + - "\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" + - "\n\nApp details:" + - "\nVERSION: ${BuildConfig.VERSION_NAME}" + - "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + - "\nFLAVOR: ${BuildConfig.FLAVOR}" + - "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}" - ) - } - context.startActivity(intent) - subjectState.clearText() - descriptionState.clearText() - } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledInputField( - inputState = subjectState, - focusRequester = subjectFocusRequester, - placeholder = stringResource(R.string.subject), - forceApple = true - ) - - Spacer(modifier = Modifier.height(12.dp)) - - StyledInputField( - inputState = descriptionState, - focusRequester = descriptionFocusRequester, - placeholder = stringResource(R.string.describe_your_issue), - singleLine = false, - forceApple = true - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt deleted file mode 100644 index 101e4bbac..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt +++ /dev/null @@ -1,97 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - val scrollState = rememberScrollState() - - val bytes = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( - 2 - )?.toByteArray() ?: byteArrayOf(0x00, 0x00) - val flipped = try { - bytes[1] == 0x02.toByte() - } catch (e: Exception) { - false - } - - val pressOnceText = stringResource(R.string.press_once) - val pressTwiceText = stringResource(R.string.press_twice) - val muteUnmuteText = stringResource(R.string.mute_unmute) - - var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) } - - val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } } - val flippedValue = action != muteUnmuteText - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - StyledList { - StyledListItem( - name = pressOnceText, - selected = pressOnceIsAction, - onClick = { - singlePressAction = pressOnceText - onCallControlValueChanged(flippedValue) - } - ) - - StyledListItem( - name = pressTwiceText, - selected = !pressOnceIsAction, - onClick = { - singlePressAction = pressTwiceText - onCallControlValueChanged(!flippedValue) - } - ) - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt deleted file mode 100644 index 7582fc3a4..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -//@Composable -//fun CameraControlScreen(viewModel: AirPodsViewModel) { -// val context = LocalContext.current -// val currentCameraAction by viewModel.cameraAction.collectAsState() -// -// fun isAppListenerServiceEnabled(context: Context): Boolean { -// val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager -// val enabledServices = -// am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) -// val serviceComponent = ComponentName(context, AppListenerService::class.java) -// return enabledServices.any { -// it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && -// it.resolveInfo.serviceInfo.name == serviceComponent.className -// } -// } -// -// fun handleSelection(action: StemPressType?) { -// if (action != null && !isAppListenerServiceEnabled(context)) { -// context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) -// } else { -// viewModel.setCameraAction(action) -// } -// } -// -// val cameraOptions = remember(currentCameraAction) { -// listOf( -// SelectItem( -// name = "Off", -// selected = currentCameraAction == null, -// onClick = { handleSelection(null) } -// ), -// SelectItem( -// name = "Press once", -// selected = currentCameraAction == StemPressType.SINGLE_PRESS, -// onClick = { handleSelection(StemPressType.SINGLE_PRESS) } -// ), -// SelectItem( -// name = "Press and hold AirPods", -// selected = currentCameraAction == StemPressType.LONG_PRESS, -// onClick = { handleSelection(StemPressType.LONG_PRESS) } -// ) -// ) -// } -// -// val backdrop = rememberLayerBackdrop() -// -// StyledScaffold( -// titleRes = stringResource(R.string.camera_control) -// ) { spacerHeight -> -// Column( -// modifier = Modifier -// .fillMaxSize() -// .layerBackdrop(backdrop) -// .padding(horizontal = 16.dp), -// verticalArrangement = Arrangement.spacedBy(16.dp) -// ) { -// Spacer(modifier = Modifier.height(spacerHeight)) -// StyledSelectList(items = cameraOptions) -// } -// } -//} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt deleted file mode 100644 index 408e6f575..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt +++ /dev/null @@ -1,762 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.visible -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.lerp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.highlight.Highlight -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState -import kotlin.math.abs -import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.milliseconds - -@Composable -fun EqualizerRoute(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = state, - topPadding = topPadding, - bottomPadding = bottomPadding, - setCustomEqEnabled = viewModel::setCustomEqEnabled, - setCustomEq = viewModel::setCustomEq - ) - } -} - -@OptIn(FlowPreview::class) -@Composable -fun EqualizerScreen( - state: AirPodsUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - setCustomEqEnabled: (Boolean) -> Unit, - setCustomEq: (Int, Int, Int) -> Unit -) { - val customEq = state.customEq - - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 - - val offsets = remember(state.customEq) { - listOf( - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) - ) - } - - LaunchedEffect(offsets) { - snapshotFlow { - Triple( - offsets[0].floatValue, - offsets[1].floatValue, - offsets[2].floatValue - ) - } - .debounce(100.milliseconds) // cool, should've been using this since the very beginning - .collect { (lowF, midF, highF) -> - val low = - 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val mid = - 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val high = - 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - - setCustomEq(low, mid, high) - } - } - - Spacer(modifier = Modifier.height(topPadding)) - - val enabled = customEq.isEnabled() - - StyledList { - StyledListItem( - name = stringResource(R.string.recommended), - selected = !enabled, - onClick = { setCustomEqEnabled(false) } - ) - - StyledListItem( - name = stringResource(R.string.custom), - selected = enabled, - onClick = { setCustomEqEnabled(true) } - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Crossfade ( - customEq.isEnabled() - ) { visible -> - Column( - modifier = Modifier - .fillMaxWidth() - .visible(visible), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - - EqualizerCard( - lowOffset = offsets[0], - midOffset = offsets[1], - highOffset = offsets[2] - ) - - val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } - - StyledButton( - onClick = { - offsets[0].floatValue = 0f - offsets[1].floatValue = 0f - offsets[2].floatValue = 0f - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - isInteractive = false, - enabled = resetButtonEnabled.value - ) { - Text( - text = stringResource(R.string.reset), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} - - -@Composable -fun EqualizerCard( - lowOffset: MutableState, - midOffset: MutableState, - highOffset: MutableState -) { - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 - - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) - ) { - val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D) - - val backdrop = rememberLayerBackdrop() - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) - ) { - Spacer(modifier = Modifier.height(42.dp)) - // Row( - // modifier = Modifier - // .fillMaxWidth() - // .padding(18.dp), - // verticalAlignment = Alignment.CenterVertically, - // horizontalArrangement = Arrangement.spacedBy(12.dp) - // ) { - // Box( - // modifier = Modifier - // .size(64.dp) - // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) - // ) - // Column( - // modifier = Modifier - // .weight(1f), - // verticalArrangement = Arrangement.Center - // ) { - // Text( - // text = "Written into Changes", - // style = TextStyle( - // fontSize = 16.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Bold, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // Spacer(modifier = Modifier.height(4.dp)) - // Text( - // text = "Avalon Emerson", - // style = TextStyle( - // fontSize = 14.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // } - // val paused = remember { mutableStateOf(false) } - // Box( - // modifier = Modifier - // .size(48.dp) - // .background(Color(0x600091FF), CircleShape) - // .clickable( - // interactionSource = remember { MutableInteractionSource() }, - // indication = null, - // ) { - // paused.value = !paused.value - // }, - // contentAlignment = Alignment.Center - // ) { - // Crossfade( - // targetState = paused.value, - // label = "media_icon" - // ) { p -> - // Text( - // text = if (p) "􀊄" else "􀊆", - // style = TextStyle( - // fontSize = 24.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = Color(0xFF0091FF), - // textAlign = TextAlign.Center - // ) - // ) - // } - // } - // } - // - // HorizontalDivider( - // thickness = 1.dp, - // color = Color(0x40888888), - // modifier = Modifier - // .padding(horizontal = 20.dp) - // .padding(bottom = 16.dp) - // ) - - Box( - modifier = Modifier.fillMaxWidth() - ) { - fun colorFromY(y: Float): Color { - val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) - val stops = listOf( - 0.0f to Color(0xFFFFA300), - 0.25f to Color(0xFFFCE600), - 0.5f to Color(0xFF00FAAF), - 0.75f to Color(0xFF00FAFF), - 1.0f to Color(0xFF00B5FF) - ) - val (start, end) = stops.zipWithNext() - .first { f <= it.second.first } - val c = (f - start.first) / (end.first - start.first) - return lerp(start.second, end.second, c) - } - - fun pathBrush( - startY: Float, - endY: Float, - ): Brush { - val stops = (0..20).map { i -> - val t = i / 20f - val y = lerp(startY, endY, t) - t to colorFromY(y) - } - - return Brush.linearGradient( - colorStops = stops.toTypedArray() - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .layerBackdrop(backdrop) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp) - ) { - Row( - modifier = Modifier - .fillMaxSize() - ) { - val dashCount = (height / 10.dp).toInt() - repeat(3) { - Box( - modifier = Modifier - .fillMaxSize() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - for (i in 1..(dashCount)) { - val t = i.toFloat() / dashCount - val centerDistance = abs(0.5f - t) - val alpha = 1f - (centerDistance * 2f) - Box( - modifier = Modifier - .height(9.dp) - .width(0.75.dp) - .background( - dashColor.copy(alpha), - RoundedCornerShape(28.dp) - ) - ) - } - } - } - } - } - - val backgroundColor = MaterialTheme.colorScheme.surface - - Canvas( - modifier = Modifier - .fillMaxSize() - ) { - val canvasWidth = size.width - - drawLine( - color = backgroundColor, - start = Offset( - x = 0f, - y = lowOffset.value + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(lowOffset.value), - start = Offset( - x = 0f, - y = lowOffset.value + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ), - strokeWidth = 8f - ) - - val lowToMidPath = Path() - lowToMidPath.moveTo( - x = 1 / 6f * canvasWidth, - y = lowOffset.value + maxOffset - ) - lowToMidPath.cubicTo( - x1 = canvasWidth * 1 / 6f + 108.dp.value, - y1 = lowOffset.value + maxOffset, - x2 = canvasWidth * 0.5f - 108.dp.value, - y2 = midOffset.value + maxOffset, - x3 = canvasWidth * 0.5f, - y3 = midOffset.value + maxOffset - ) - drawPath( - color = backgroundColor, - path = lowToMidPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - lowOffset.value, - midOffset.value - ), - path = lowToMidPath, - style = Stroke(width = 8f) - ) - - val midToHighPath = Path() - midToHighPath.moveTo( - x = 0.5f * canvasWidth, - y = midOffset.value + maxOffset - ) - midToHighPath.cubicTo( - x1 = canvasWidth * 0.5f + 108.dp.value, - y1 = midOffset.value + maxOffset, - x2 = canvasWidth * 5 / 6f - 108.dp.value, - y2 = highOffset.value + maxOffset, - x3 = canvasWidth * 5 / 6f, - y3 = highOffset.value + maxOffset - ) - drawPath( - color = backgroundColor, - path = midToHighPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - midOffset.value, - highOffset.value - ), - path = midToHighPath, - style = Stroke(width = 8f) - ) - drawLine( - color = backgroundColor, - start = Offset( - x = 5 / 6f * canvasWidth, - y = highOffset.value + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = highOffset.value + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(highOffset.value), - start = Offset( - x = 5 / 6f * canvasWidth, - y = highOffset.value + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = highOffset.value + maxOffset - ), - strokeWidth = 8f - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Low".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Mid".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "High".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp), - - verticalAlignment = Alignment.CenterVertically - ) { - for (i in 0..2) { - Row( - modifier = Modifier - .weight(1f), - horizontalArrangement = Arrangement.Center - ) { - val pressed = remember { mutableStateOf(false) } - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt() - ) - }, - contentAlignment = Alignment.Center - ) { - Crossfade( - pressed.value - ) { - Box( - modifier = Modifier - .size(96.dp) - .then( - if (it) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { CircleShape }, - highlight = { - Highlight.Ambient - }, - onDrawSurface = { - drawCircle( - color = Color.White.copy( - 0.2f - ), - radius = size.height - ) - drawCircle( - color = colorFromY( - when (i) { - 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value - else -> 0f - } - ), - style = Stroke(2.dp.value), - radius = size.height / 2 - ) - }, - effects = { - lens( - refractionHeight = 32f.dp.value, - refractionAmount = size.height - ) - } - ) - } else Modifier - ) - ) - } - Box( - modifier = Modifier - .size(18.dp) - .background( - colorFromY( - when (i) { - 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value - else -> 0f - } - ), - CircleShape - ) - .border( - 2.5.dp, - MaterialTheme.colorScheme.surfaceContainer, - CircleShape - ) - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - when (i) { - 0 -> { - lowOffset.value = - (lowOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - - 1 -> { - midOffset.value = - (midOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - - 2 -> { - highOffset.value = - (highOffset.value + delta).coerceIn( - -maxOffset, - maxOffset - ) - } - } - }, - onDragStarted = { - pressed.value = true - }, - onDragStopped = { - pressed.value = false - } - ) - ) - } - } - } - } - } - } - } -} - -@Preview(name = "Apple") -@Composable -fun EqualizerScreenPreviewApple() { - LibrePodsTheme( - m3eEnabled = false - ) { - Box ( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = demoState, - setCustomEqEnabled = { }, - setCustomEq = {_, _, _ -> } - ) - } - } -} - -@Preview(name = "Material") -@Composable -fun EqualizerScreenPreviewMaterial() { - LibrePodsTheme( - m3eEnabled = true - ) { - val state = remember { mutableStateOf(demoState) } - Box ( - modifier = Modifier - .wrapContentHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - state = state.value, - setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) }, - setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))} - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt deleted file mode 100644 index 79a14af1f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ /dev/null @@ -1,405 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - - -// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.screens - -import android.graphics.Paint -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.HeadTracking -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs - -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) -@Composable -fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() - DisposableEffect(Unit) { - viewModel.startHeadTracking() - onDispose { - viewModel.stopHeadTracking() - } - } - val isDarkTheme = isSystemInDarkTheme() - if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black - - val backdrop = rememberLayerBackdrop() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - var gestureText by remember { mutableStateOf("") } - val coroutineScope = rememberCoroutineScope() - - var lastClickTime by remember { mutableLongStateOf(0L) } - var shouldExplode by remember { mutableStateOf(false) } - - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - - Column ( - modifier = Modifier - .fillMaxWidth() - .layerBackdrop(backdrop) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - - if (!state.isPremium) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - - StyledToggle( - label = "Head Gestures", - checked = state.headGesturesEnabled, - onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, - enabled = state.isPremium || state.headGesturesEnabled, - description = stringResource(R.string.head_gestures_details), - header = true - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Spacer(modifier = Modifier.height(16.dp)) - Text( - "Velocity", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) - ) - Plot() - - Spacer(modifier = Modifier.height(16.dp)) - - LaunchedEffect(gestureText) { - if (gestureText.isNotEmpty()) { - lastClickTime = System.currentTimeMillis() - delay(3000) - if (System.currentTimeMillis() - lastClickTime >= 3000) { - shouldExplode = true - } - } - } - } - val gestureTextValue = stringResource(R.string.shake_your_head_or_nod) - StyledButton( - onClick = { - gestureText = gestureTextValue - coroutineScope.launch { - val accepted = ServiceManager.getService()?.testHeadGestures() ?: false - gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." - } - }, - backdrop = backdrop, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - maxScale = 0.05f - ) { - Text( - "Test Head Gestures", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = MaterialTheme.colorScheme.onSecondaryContainer - ), - ) - } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.padding(top = 12.dp, bottom = 24.dp) - ) { - AnimatedContent( - targetState = gestureText, - transitionSpec = { - (fadeIn( - animationSpec = tween(300) - ) + slideInVertically( - initialOffsetY = { 40 }, - animationSpec = tween(300) - )).togetherWith(fadeOut(animationSpec = tween(150))) - } - ) { text -> - if (shouldExplode) { - LaunchedEffect(Unit) { - CoroutineScope(coroutineScope.coroutineContext).launch { - delay(750) - gestureText = "" - } - } - Text( - text = text, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - textAlign = TextAlign.Center - ), - color = MaterialTheme.colorScheme.onBackground - ) - } else { - Text( - text = text, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor, - textAlign = TextAlign.Center - ), - modifier = Modifier - .fillMaxWidth() - ) - } - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} - -@Composable -private fun Plot() { - val acceleration by HeadTracking.acceleration.collectAsState() - val maxPoints = 100 - val points = remember { mutableStateListOf>() } - val darkTheme = isSystemInDarkTheme() - - var maxAbs by remember { mutableFloatStateOf(1000f) } - - LaunchedEffect(acceleration) { - points.add(Pair(acceleration.horizontal, acceleration.vertical)) - if (points.size > maxPoints) { - points.removeAt(0) - } - - val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) } - maxAbs = maxOf(currentMax * 1.2f, 1000f) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - shape = RoundedCornerShape(28.dp) - ) { - val horizontalColor = MaterialTheme.colorScheme.primary - val verticalColor = MaterialTheme.colorScheme.onPrimary - - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - Canvas( - modifier = Modifier.fillMaxSize() - ) { - val width = size.width - val height = size.height - val xScale = width / maxPoints - val yScale = (height - 40.dp.toPx()) / (maxAbs * 2) - val zeroY = height / 2 - - val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f) - - for (i in 0..maxPoints step 10) { - val x = i * xScale - drawLine( - color = gridColor, - start = Offset(x, 0f), - end = Offset(x, height), - strokeWidth = 1.dp.toPx() - ) - } - - val gridStep = maxAbs / 4 - for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) { - val y = zeroY - value * yScale - drawLine( - color = gridColor, - start = Offset(0f, y), - end = Offset(width, y), - strokeWidth = 1.dp.toPx() - ) - } - - drawLine( - color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f), - start = Offset(0f, zeroY), - end = Offset(width, zeroY), - strokeWidth = 1.5f.dp.toPx() - ) - - if (points.size > 1) { - for (i in 0 until points.size - 1) { - val x1 = i * xScale - val x2 = (i + 1) * xScale - - drawLine( - color = horizontalColor, - start = Offset(x1, zeroY - points[i].first * yScale), - end = Offset(x2, zeroY - points[i + 1].first * yScale), - strokeWidth = 2.dp.toPx() - ) - - drawLine( - color = verticalColor, - start = Offset(x1, zeroY - points[i].second * yScale), - end = Offset(x2, zeroY - points[i + 1].second * yScale), - strokeWidth = 2.dp.toPx() - ) - } - } - - drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.RIGHT - } - - drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) - drawText("0", 30.dp.toPx(), height/2, paint) - drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint) - } - - val legendY = 15.dp.toPx() - val textOffsetY = legendY + 5.dp.toPx() / 2 - - drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) - drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.LEFT - } - drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) - } - - drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) - drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.LEFT - } - drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt deleted file mode 100644 index e4b78c4b5..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.annotation.SuppressLint -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.Job -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.HearingAidSettings -import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi - -private const val TAG = "HearingAidAdjustments" - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) -@Composable -fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { - val verticalScrollState = rememberScrollState() - val state by viewModel.uiState.collectAsState() - - val debounceJob = remember { mutableStateOf(null) } - - val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) } - val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) } - val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) } - val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) } - val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } - - val initialized = rememberSaveable { mutableStateOf(false) } - - val hearingAidSettings = remember { mutableStateOf( - HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = 0f, - rightAmplification = 0f, - leftTone = 0f, - rightTone = 0f, - leftConversationBoost = false, - rightConversationBoost = false, - leftAmbientNoiseReduction = 0f, - rightAmbientNoiseReduction = 0f, - netAmplification = 0f, - balance = 0f, - ownVoiceAmplification = 0f - ) - ) } - - LaunchedEffect(state.hearingAidData) { - parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed -> - amplificationSliderValue.floatValue = parsed.netAmplification - balanceSliderValue.floatValue = parsed.balance - toneSliderValue.floatValue = parsed.leftTone - ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction - conversationBoostEnabled.value = parsed.leftConversationBoost - leftEQ.value = parsed.leftEQ.copyOf() - rightEQ.value = parsed.rightEQ.copyOf() - ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification - initialized.value = true - } - } - - LaunchedEffect( - amplificationSliderValue.floatValue, - balanceSliderValue.floatValue, - toneSliderValue.floatValue, - conversationBoostEnabled.value, - ambientNoiseReductionSliderValue.floatValue, - ownVoiceAmplification.floatValue - ) { - if (!initialized.value) return@LaunchedEffect - hearingAidSettings.value = HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, - rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, - leftTone = toneSliderValue.floatValue, - rightTone = toneSliderValue.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - netAmplification = amplificationSliderValue.floatValue, - balance = balanceSliderValue.floatValue, - ownVoiceAmplification = ownVoiceAmplification.floatValue - ) - Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue) - } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - StyledSlider( - label = stringResource(R.string.amplification), - valueRange = -1f..1f, - value = amplificationSliderValue.floatValue, - onValueChange = { - amplificationSliderValue.floatValue = it - }, - startIcon = "􀊥", - endIcon = "􀊩", - independent = true, - ) - - StyledToggle( - label = stringResource(R.string.swipe_to_control_amplification), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) }, - description = stringResource(R.string.swipe_amplification_description) - ) - - StyledSlider( - label = stringResource(R.string.balance), - valueRange = -1f..1f, - value = balanceSliderValue.floatValue, - onValueChange = { - balanceSliderValue.floatValue = it - }, - snapPoints = listOf(-1f, 0f, 1f), - startLabel = stringResource(R.string.left), - endLabel = stringResource(R.string.right), - independent = true, - ) - - StyledSlider( - label = stringResource(R.string.tone), - valueRange = -1f..1f, - value = toneSliderValue.floatValue, - onValueChange = { - toneSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.darker), - endLabel = stringResource(R.string.brighter), - independent = true, - ) - - StyledSlider( - label = stringResource(R.string.ambient_noise_reduction), - valueRange = 0f..1f, - value = ambientNoiseReductionSliderValue.floatValue, - onValueChange = { - ambientNoiseReductionSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.less), - endLabel = stringResource(R.string.more), - independent = true, - ) - - StyledToggle( - label = stringResource(R.string.conversation_boost), - checked = conversationBoostEnabled.value, - onCheckedChange = { conversationBoostEnabled.value = it }, - description = stringResource(R.string.conversation_boost_description) - ) - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt deleted file mode 100644 index 4a040a161..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt +++ /dev/null @@ -1,243 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.annotation.SuppressLint -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.parseTransparencySettingsResponse -import me.kavishdevar.librepods.data.sendTransparencySettings -import me.kavishdevar.librepods.presentation.components.ConfirmationDialog -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi - -private const val TAG = "AccessibilitySettings" - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) -@Composable -fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { - val verticalScrollState = rememberScrollState() - val backdrop = rememberLayerBackdrop() - - val showDialog = remember { mutableStateOf(false) } - val initialLoad = remember { mutableStateOf(true) } - - val state by viewModel.uiState.collectAsState() - - val hearingAidEnabled = remember { - val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID] - val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG] - mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) - } - - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .layerBackdrop(backdrop) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - -// val mediaAssistEnabled = remember { mutableStateOf(false) } -// val adjustMediaEnabled = remember { mutableStateOf(false) } -// val adjustPhoneEnabled = remember { mutableStateOf(false) } - - LaunchedEffect(hearingAidEnabled.value) { - if (hearingAidEnabled.value && !initialLoad.value) { - showDialog.value = true - } else if (!hearingAidEnabled.value && !initialLoad.value) { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02)) - viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte()) - hearingAidEnabled.value = false - } - initialLoad.value = false - } - -// fun onAdjustPhoneChange(value: Boolean) { -// // TODO -// } - -// fun onAdjustMediaChange(value: Boolean) { -// // TODO -// } - - StyledList (title = stringResource(R.string.hearing_aid)) { - StyledToggle( - label = stringResource(R.string.hearing_aid), - checked = hearingAidEnabled.value, - onCheckedChange = { hearingAidEnabled.value = it }, - ) - StyledListItem( - name = stringResource(R.string.adjustments), - onClick = onNavigateHearingAidAdjustments, - ) - } - - Text( - text = stringResource(R.string.hearing_aid_description), - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - - StyledListItem( - name = stringResource(R.string.update_hearing_test), - onClick = onNavigateHearingTest, - ) - - // not implemented yet - - // StyledToggle( - // titleRes = stringResource(R.string.media_assist), - // label = stringResource(R.string.media_assist), - // checkedState = mediaAssistEnabled, - // independent = true, - // descriptionRes = stringResource(R.string.media_assist_description) - // ) - - // Spacer(modifier = Modifier.height(8.dp)) - - // Column ( - // modifier = Modifier - // .fillMaxWidth() - // .background(backgroundColor, RoundedCornerShape(28.dp)) - // ) { - // StyledToggle( - // label = stringResource(R.string.adjust_media), - // checkedState = adjustMediaEnabled, - // onCheckedChange = { onAdjustMediaChange(it) }, - // independent = false - // ) - // HorizontalDivider( - // thickness = 1.dp, - // color = Color(0x40888888), - // modifier = Modifier - // .padding(horizontal = 12.dp) - // ) - - // StyledToggle( - // label = stringResource(R.string.adjust_calls), - // checkedState = adjustPhoneEnabled, - // onCheckedChange = { onAdjustPhoneChange(it) }, - // independent = false - // ) - // } - Spacer(modifier = Modifier.height(bottomPadding)) - } - - ConfirmationDialog( - showDialog = showDialog, - title = "Enable Hearing Aid", - message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.", - confirmText = "Enable", - dismissText = "Cancel", - onConfirm = { - showDialog.value = false - val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte() - if (!enrolled) { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) - } else { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) - } - viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte()) - hearingAidEnabled.value = true - CoroutineScope(Dispatchers.IO).launch { - try { - if (state.hearingAidData.isEmpty()) { - Log.w(TAG, "read failed") - return@launch - } - val parsed = parseTransparencySettingsResponse(state.hearingAidData) - if (parsed == null) { - Log.w(TAG, "transparency parse failed") - return@launch - } - val disabledSettings = parsed.copy(enabled = false) - sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) - } catch (e: Exception) { - Log.e(TAG, "Error disabling transparency: ${e.message}") - } - } - }, - onDismiss = { - hearingAidEnabled.value = false - showDialog.value = false - }, - backdrop = backdrop - ) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt deleted file mode 100644 index 9c83f5077..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@Composable -fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { - val backdrop = rememberLayerBackdrop() - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .layerBackdrop(backdrop) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - - if (state.vendorIdHook) { - StyledToggle( - title = stringResource(R.string.environmental_noise), - label = stringResource(R.string.loud_sound_reduction), - description = stringResource(R.string.loud_sound_reduction_description), - checked = state.loudSoundReductionEnabled, - onCheckedChange = { - viewModel.setATTCharacteristicValue( - ATTHandles.LOUD_SOUND_REDUCTION, - byteArrayOf(if (it) 1.toByte() else 0.toByte()) - ) - }, - enabled = state.isPremium - ) - - Spacer(modifier = Modifier.height(12.dp)) - } - StyledToggle( - title = stringResource(R.string.workspace_use), - label = stringResource(R.string.ppe), - description = stringResource(R.string.workspace_use_description), - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull( - 0 - )?.toInt() == 1, - onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it - ) - }, - enabled = state.isPremium - ) - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt deleted file mode 100644 index 2702da108..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme - -@Composable -fun LoadingScreen() { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center - ) { - CircularWavyProgressIndicator( - modifier = Modifier - .size(120.dp) - ) - } -} - -@Preview -@Composable -fun LoadingScreenPreview() { - LibrePodsTheme { - LoadingScreen() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt deleted file mode 100644 index 91bf97502..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt +++ /dev/null @@ -1,101 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@Composable -fun MicrophoneSettingsRoute( - viewModel: AirPodsViewModel -) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE - - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - MicrophoneSettingsScreen( - selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0, - topPadding = topPadding, - bottomPadding = bottomPadding, - onMicrophoneSettingsChanged = { - viewModel.setControlCommandInt(id, it) - } - ) - } -} - -@Composable -fun MicrophoneSettingsScreen( - selectedMode: Int, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - onMicrophoneSettingsChanged: (Int) -> Unit -) { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - StyledList { - StyledListItem( - name = stringResource(R.string.microphone_automatic), - selected = selectedMode == 0, - onClick = { onMicrophoneSettingsChanged(0) } - ) - - StyledListItem( - name = stringResource(R.string.microphone_always_right), - selected = selectedMode == 1, - onClick = { onMicrophoneSettingsChanged(1) } - ) - - StyledListItem( - name = stringResource(R.string.microphone_always_left), - selected = selectedMode == 2, - onClick = { onMicrophoneSettingsChanged(2) } - ) - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt deleted file mode 100644 index d8663111e..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.annotation.SuppressLint -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer -import com.mikepenz.aboutlibraries.ui.compose.produceLibraries -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.Job -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import kotlin.io.encoding.ExperimentalEncodingApi - -private var debounceJob: Job? = null - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) -@Composable -fun OpenSourceLicensesScreen() { - val backdrop = rememberLayerBackdrop() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - val context = LocalContext.current - val libraries by produceLibraries { - context.resources.openRawResource(R.raw.aboutlibraries) - .bufferedReader() - .use { it.readText() } - } - LibrariesContainer( - libraries = libraries, - modifier = Modifier - .padding(0.dp) - .fillMaxSize() - ) - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt deleted file mode 100644 index 247eacb5f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt +++ /dev/null @@ -1,233 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.screens - -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.data.StemAction -import me.kavishdevar.librepods.presentation.components.ListItemOrientation -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.experimental.and -import kotlin.io.encoding.ExperimentalEncodingApi - -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) { - val state by viewModel.uiState.collectAsState() - - val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 - - Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}") - Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}") - Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}") - Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") - Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") - - val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - val scrollState = rememberScrollState() - - Column ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - StyledList { - StyledListItem( - name = stringResource(R.string.noise_control), - selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, - onClick = { - viewModel.setLongPressAction( - name, - StemAction.CYCLE_NOISE_CONTROL_MODES - ) - } - ) - - StyledListItem( - name = stringResource(R.string.digital_assistant), - selected = longPressAction == StemAction.DIGITAL_ASSISTANT, - onClick = { - viewModel.setLongPressAction( - name, - StemAction.DIGITAL_ASSISTANT - ) - }, - enabled = state.isPremium - ) - } - - if (!state.isPremium) { - Spacer(modifier = Modifier.height(24.dp)) - StyledButton( - onClick = navigateToPurchase, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - - if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { - Spacer(modifier = Modifier.height(32.dp)) - - val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 - - StyledList( - title = stringResource(R.string.noise_control), - description = stringResource(R.string.press_and_hold_noise_control_description) - ) { - if (state.offListeningMode) { - StyledListItem( - name = stringResource(R.string.off), - description = stringResource(R.string.listening_mode_off_description), - selected = (currentByte and 0x01) != 0, - onClick = { - viewModel.toggleListeningMode(0x01) - }, - orientation = ListItemOrientation.Vertical, - leadingContent = { - Icon( - painter = painterResource(R.drawable.noise_cancellation), - contentDescription = "Icon", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .height(42.dp) - .wrapContentWidth() - ) - } - ) - } - - StyledListItem( - name = stringResource(R.string.transparency), - description = stringResource(R.string.listening_mode_transparency_description), - selected = (currentByte and 0x04) != 0, - onClick = { - viewModel.toggleListeningMode(0x04) - }, - orientation = ListItemOrientation.Vertical, - leadingContent = { - Icon( - painter = painterResource(R.drawable.transparency), - contentDescription = "Icon", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .height(42.dp) - .wrapContentWidth() - ) - } - ) - - StyledListItem( - name = stringResource(R.string.adaptive), - description = stringResource(R.string.listening_mode_adaptive_description), - selected = (currentByte and 0x08) != 0, - onClick = { - viewModel.toggleListeningMode(0x08) - }, - orientation = ListItemOrientation.Vertical, - leadingContent = { - Icon( - painter = painterResource(R.drawable.adaptive), - contentDescription = "Icon", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .height(42.dp) - .wrapContentWidth() - ) - } - ) - - StyledListItem( - name = stringResource(R.string.noise_cancellation), - description = stringResource(R.string.listening_mode_noise_cancellation_description), - selected = (currentByte and 0x02) != 0, - onClick = { - viewModel.toggleListeningMode(0x02) - }, - orientation = ListItemOrientation.Vertical, - leadingContent = { - Icon( - painter = painterResource(R.drawable.noise_cancellation), - contentDescription = "Icon", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .height(42.dp) - .wrapContentWidth() - ) - } - ) - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt deleted file mode 100644 index c5d1642c3..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.ListItemOrientation -import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle -import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.navigation.Screen -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel -import me.kavishdevar.librepods.utils.XposedState - -@Composable -fun PurchaseScreen( - viewModel: PurchaseViewModel = viewModel(), - backStack: SnapshotStateList -) { - val context = LocalContext.current - val scrollState = rememberScrollState() - - val state by viewModel.uiState.collectAsState() - - val backdrop = rememberLayerBackdrop() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .layerBackdrop(backdrop) - .verticalScroll(scrollState) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - LaunchedEffect(state.isPremium) { - if (state.isPremium) { - if (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) - } - } - } - if (!state.isPremium) { - StyledList(title = stringResource(R.string.free_features)) { - StyledListItem( - name = stringResource(R.string.ear_detection), - description = stringResource(R.string.ear_detection_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.battery), - description = stringResource(R.string.battery_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.noise_control), - description = stringResource(R.string.noise_control_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - if (XposedState.isAvailable) { - StyledListItem( - name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})", - description = stringResource(R.string.hearing_aid_description) - .substringBefore("\n\n"), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) { - StyledListItem( - name = stringResource(R.string.conversational_awareness), - description = stringResource(R.string.conversational_awareness_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.digital_assistant_on_long_press), - description = stringResource(R.string.digital_assistant_on_long_press_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.head_gestures), - description = stringResource(R.string.head_gestures_details), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.advanced_device_settings), - description = stringResource(R.string.advanced_device_settings_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.automatic_connection), - description = stringResource(R.string.automatic_connection_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.customizations), - description = stringResource(R.string.customizations_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - - StyledListItem( - name = stringResource(R.string.support_the_development), - description = stringResource(R.string.support_development_description), - enabled = false, - orientation = ListItemOrientation.Vertical - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - StyledButton( - onClick = { - viewModel.purchase(context) - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary, - materialButtonStyle = MaterialButtonStyle.Filled - ) { - Text( - stringResource(R.string.buy_price, state.price), - style = MaterialTheme.typography.bodyMediumEmphasized, - color = MaterialTheme.colorScheme.onPrimary - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledButton( - onClick = { - viewModel.restorePurchases() - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - isInteractive = false, - materialButtonStyle = MaterialButtonStyle.Outlined - ) { - Text( - stringResource(R.string.restore_purchases), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt deleted file mode 100644 index c9ca9953a..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt +++ /dev/null @@ -1,471 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.carousel.CarouselDefaults -import androidx.compose.material3.carousel.HorizontalUncontainedCarousel -import androidx.compose.material3.carousel.rememberCarouselState -import androidx.compose.material3.toPath -import androidx.compose.material3.toShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.rotate -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO -import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES -import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE -import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.graphics.shapes.Morph -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.updates.UpdateItem -import me.kavishdevar.librepods.data.updates.updates -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import kotlin.math.min - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ReleaseNotesScreen( - updates: List, - releaseNotesShown: () -> Unit -) { - val state = rememberCarouselState( - initialItem = 0, - itemCount = { updates.size + 1 } - ) - - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - - LibrePodsTheme(m3eEnabled = true) { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - Text( - text = stringResource(R.string.what_s_new), - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - - val versionName = BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play") - val url = "https://github.com/kavishdevar/librepods/releases/v$versionName" - val fullText = "${stringResource(R.string.version)} $versionName" - val textColor = MaterialTheme.colorScheme.primary - - val annotatedString = buildAnnotatedString { - append(fullText) - addLink( - url = LinkAnnotation.Url( - url = url, - styles = TextLinkStyles( - style = SpanStyle(color = textColor) - ) - ), - start = 0, - end = fullText.length - ) - } - Text( - text = annotatedString, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.tertiary, - textDecoration = TextDecoration.Underline - ) - - Spacer(modifier = Modifier.height(16.dp)) - HorizontalUncontainedCarousel( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - state = state, - itemSpacing = 16.dp, - contentPadding = PaddingValues(horizontal = 16.dp), - itemWidth = LocalWindowInfo.current.containerDpSize.width - 64.dp, - flingBehavior = CarouselDefaults.singleAdvanceFlingBehavior(state) - ) { index -> - - val shape = rememberMaskShape(RoundedCornerShape(48.dp)) - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = shape, - modifier = Modifier.fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxSize() - ) { - if (index != updates.size) { - val updateItem = updates[index] - - val deviceBorderColor = MaterialTheme.colorScheme.tertiary - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 16.dp) - .heightIn(max = 700.dp) - .align(Alignment.TopCenter) - .drawWithCache { - val path = createTopWavyRectPath( - width = size.width, - height = size.height, - cornerRadius = 52.dp.toPx(), - amplitude = 4.dp.toPx(), - wavelength = 36.dp.toPx() - ) - onDrawWithContent { - drawContent() - - drawPath( - path, - color = deviceBorderColor, - style = Stroke( - width = 4.dp.toPx() - ) - ) - } - } - - ) { - Box( - modifier = Modifier - .padding(2.dp) - .clip(RoundedCornerShape(50.dp)) - ) { - CompositionLocalProvider( - LocalDensity provides Density( - density = LocalDensity.current.density * 0.8f, - fontScale = LocalDensity.current.fontScale - ), - LocalDesignSystem provides if (m3eEnabled) DesignSystem.Material else DesignSystem.Apple - ) { - updateItem.demoComposeable() - } - } - } - - Box( - modifier = Modifier - .padding(12.dp) - .aspectRatio(1f) - .background( - MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f), - MaterialShapes.Arch.toShape() - ) - .align(Alignment.BottomCenter) - ) { - Column( - modifier = Modifier - .padding(32.dp) - .align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(updateItem.titleRes), - style = MaterialTheme.typography.displayMediumEmphasized, - color = MaterialTheme.colorScheme.onTertiary, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(updateItem.descriptionRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiary, - textAlign = TextAlign.Center - ) - } - } - } else { - var pressed by remember { mutableStateOf(false) } - - val morph = remember { - Morph( - MaterialShapes.Cookie7Sided.normalized(), - MaterialShapes.SoftBurst.normalized() - ) - } - - val morphProgress by animateFloatAsState( - targetValue = if (pressed) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "morph", - ) - - val rotationSpeed by animateFloatAsState( - targetValue = if (pressed) 0f else 1f, - animationSpec = tween(1200), - label = "rotationSpeed" - ) - - var rotation by remember { mutableFloatStateOf(0f) } - - LaunchedEffect(Unit) { - var lastFrame = withFrameNanos { it } - - while (true) { - val frame = withFrameNanos { it } - - val dt = (frame - lastFrame) / 1_000_000_000f - lastFrame = frame - - rotation += 60f * rotationSpeed * dt - } - } - - val path = remember { Path() } - val matrix = remember { Matrix() } - - val tertiary = MaterialTheme.colorScheme.tertiary - - Box( - modifier = Modifier - .fillMaxSize() - .align(Alignment.Center) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - pressed = true - tryAwaitRelease() - pressed = false - } - ) - } - ) { - Box( - modifier = Modifier - .size(260.dp) - .align(Alignment.Center) - .pointerInput(Unit) { - detectTapGestures( - onTap = { releaseNotesShown() }, - onPress = { - pressed = true - tryAwaitRelease() - pressed = false - } - ) - } - .drawBehind { - val shapePath = morph.toPath( - progress = morphProgress, - path = path - ) - - val bounds = shapePath.getBounds() - - val scale = min( - size.width / bounds.width, - size.height / bounds.height - ) * 0.9f - - matrix.reset() - matrix.scale(scale, scale) - - shapePath.transform(matrix) - - shapePath.translate( - size.center - - shapePath.getBounds().center - ) - - rotate(rotation) { - drawPath( - path = shapePath, - color = tertiary - ) - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowForward, - contentDescription = null, - modifier = Modifier.size(100.dp), - tint = MaterialTheme.colorScheme.onTertiary - ) - } - } - } - } - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } - } -} - -@Preview(uiMode = UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL) -@Preview(uiMode = UI_MODE_NIGHT_NO, wallpaper = RED_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL) -@Composable -fun ReleaseNotesScreenPreview() { - LibrePodsTheme( - m3eEnabled = false - ) { - ReleaseNotesScreen( - updates = updates, - releaseNotesShown = { } - ) - } -} - -// ai gen'd helper -fun createTopWavyRectPath( - width: Float, - height: Float, - cornerRadius: Float, - amplitude: Float, - wavelength: Float -): Path { - return Path().apply { - moveTo(cornerRadius, 0f) - - var x = cornerRadius - - while (x < width - cornerRadius - wavelength) { - quadraticTo( - x + wavelength / 4f, - -amplitude, - x + wavelength / 2f, - 0f - ) - - quadraticTo( - x + wavelength * 3f / 4f, - amplitude, - x + wavelength, - 0f - ) - - x += wavelength - } - - arcTo( - rect = Rect( - left = width - 2 * cornerRadius, - top = 0f, - right = width, - bottom = 2 * cornerRadius - ), - startAngleDegrees = -90f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - lineTo(width, height - cornerRadius) - - arcTo( - rect = Rect( - left = width - 2 * cornerRadius, - top = height - 2 * cornerRadius, - right = width, - bottom = height - ), - startAngleDegrees = 0f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - lineTo(cornerRadius, height) - - arcTo( - rect = Rect( - left = 0f, - top = height - 2 * cornerRadius, - right = 2 * cornerRadius, - bottom = height - ), - startAngleDegrees = 90f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - lineTo(0f, cornerRadius) - - arcTo( - rect = Rect( - left = 0f, - top = 0f, - right = 2 * cornerRadius, - bottom = 2 * cornerRadius - ), - startAngleDegrees = 180f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - close() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt deleted file mode 100644 index 44e97e6a4..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.screens - -import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.unit.dp -import androidx.core.content.edit -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.presentation.components.StyledInputField -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi - - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) -@Composable -fun RenameScreen(viewModel: AirPodsViewModel) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() - } - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - val name = sharedPreferences.getString("name", "")?: "" - val textFieldState = rememberTextFieldState(initialText = name) - - LaunchedEffect(textFieldState.text) { - sharedPreferences.edit {putString("name", textFieldState.text as String?)} - viewModel.setName(textFieldState.text.toString()) - } - - StyledInputField( - textFieldState, - focusRequester - ) - - Spacer(modifier = Modifier.height(bottomPadding)) - - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt deleted file mode 100644 index c6700e57f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt +++ /dev/null @@ -1,365 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -// import me.kavishdevar.librepods.utils.RadareOffsetFinder -import android.annotation.SuppressLint -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.data.TransparencySettings -import me.kavishdevar.librepods.data.parseTransparencySettingsResponse -import me.kavishdevar.librepods.data.sendTransparencySettings -import me.kavishdevar.librepods.presentation.components.StyledSlider -import me.kavishdevar.librepods.presentation.components.StyledToggle -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import kotlin.io.encoding.ExperimentalEncodingApi - -private const val TAG = "TransparencySettings" - -@SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) -@Composable -fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - val verticalScrollState = rememberScrollState() - - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) - val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) - val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) - - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - - val enabled = rememberSaveable { mutableStateOf(false) } - val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } - val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) } - val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) } - val eq = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } - ) - ) { mutableStateOf(FloatArray(8)) } - val phoneMediaEQ = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } - ) - ) { mutableStateOf(FloatArray(8) { 0.5f }) } - - val initialized = rememberSaveable { mutableStateOf(false) } - - val transparencySettings = remember { - mutableStateOf( - TransparencySettings( - enabled = enabled.value, - leftEQ = eq.value, - rightEQ = eq.value, - leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, - rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, - leftTone = toneSliderValue.floatValue, - rightTone = toneSliderValue.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - netAmplification = amplificationSliderValue.floatValue, - balance = balanceSliderValue.floatValue - ) - ) - } - - LaunchedEffect( - enabled.value, - amplificationSliderValue.floatValue, - balanceSliderValue.floatValue, - toneSliderValue.floatValue, - conversationBoostEnabled.value, - ambientNoiseReductionSliderValue.floatValue, - eq.value - ) { - if (!initialized.value) return@LaunchedEffect - transparencySettings.value = TransparencySettings( - enabled = enabled.value, - leftEQ = eq.value, - rightEQ = eq.value, - leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, - rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, - leftTone = toneSliderValue.floatValue, - rightTone = toneSliderValue.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - netAmplification = amplificationSliderValue.floatValue, - balance = balanceSliderValue.floatValue - ) - Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") - sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) - } - - LaunchedEffect(state.transparencyData) { - val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect - Log.d(TAG, "Initial transparency settings: $parsedSettings") - enabled.value = parsedSettings.enabled - amplificationSliderValue.floatValue = parsedSettings.netAmplification - balanceSliderValue.floatValue = parsedSettings.balance - toneSliderValue.floatValue = parsedSettings.leftTone - ambientNoiseReductionSliderValue.floatValue = - parsedSettings.leftAmbientNoiseReduction - conversationBoostEnabled.value = parsedSettings.leftConversationBoost - if (!eq.value.contentEquals(parsedSettings.leftEQ)) { - eq.value = parsedSettings.leftEQ.copyOf() - } - initialized.value = true - } - - if (state.vendorIdHook) { - StyledToggle( - label = stringResource(R.string.transparency_mode), - checked = enabled.value, - description = stringResource(R.string.customize_transparency_mode_description), - onCheckedChange = { enabled.value = it } - ) - Spacer(modifier = Modifier.height(4.dp)) - StyledSlider( - label = stringResource(R.string.amplification), - valueRange = -1f..1f, - value = amplificationSliderValue.floatValue, - onValueChange = { - amplificationSliderValue.floatValue = it - }, - startIcon = "􀊥", - endIcon = "􀊩", - independent = true - ) - - StyledSlider( - label = stringResource(R.string.balance), - valueRange = -1f..1f, - value = balanceSliderValue.floatValue, - onValueChange = { - balanceSliderValue.floatValue = it - }, - snapPoints = listOf(-1f, 0f, 1f), - startLabel = stringResource(R.string.left), - endLabel = stringResource(R.string.right), - independent = true, - ) - - StyledSlider( - label = stringResource(R.string.tone), - valueRange = -1f..1f, - value = toneSliderValue.floatValue, - onValueChange = { - toneSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.darker), - endLabel = stringResource(R.string.brighter), - independent = true, - ) - - StyledSlider( - label = stringResource(R.string.ambient_noise_reduction), - valueRange = 0f..1f, - value = ambientNoiseReductionSliderValue.floatValue, - onValueChange = { - ambientNoiseReductionSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.less), - endLabel = stringResource(R.string.more), - independent = true, - ) - - StyledToggle( - label = stringResource(R.string.conversation_boost), - checked = conversationBoostEnabled.value, - description = stringResource(R.string.conversation_boost_description), - onCheckedChange = { conversationBoostEnabled.value = it } - ) - - Text( - text = stringResource(R.string.equalizer), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(16.dp, bottom = 4.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween - ) { - for (i in 0 until 8) { - val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .height(38.dp) - ) { - Text( - text = String.format("%.2f", eqValue.floatValue), - fontSize = 12.sp, - color = textColor, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Slider( - value = eqValue.floatValue, - onValueChange = { newVal -> - eqValue.floatValue = newVal - val newEQ = eq.value.copyOf() - newEQ[i] = eqValue.floatValue - eq.value = newEQ - }, - valueRange = 0f..100f, - modifier = Modifier - .fillMaxWidth(0.9f) - .height(36.dp), - colors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = trackColor - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) - .shadow(4.dp, CircleShape) - .background(thumbColor, CircleShape) - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) - { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth(eqValue.floatValue / 100f) - .height(4.dp) - .background( - activeTrackColor, - RoundedCornerShape(4.dp) - ) - ) - } - } - ) - - Text( - text = stringResource(R.string.band_label, i + 1), - fontSize = 12.sp, - color = textColor, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt deleted file mode 100644 index 38d299e36..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt +++ /dev/null @@ -1,920 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.content.Intent -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.FileProvider -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.utils.LogCollector -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -fun CustomIconButton( - onClick: () -> Unit, - content: @Composable () -> Unit -) { - Box( - modifier = Modifier - .clickable(onClick = onClick) - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - content() - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) -@Composable -fun TroubleshootingScreen() { - val context = LocalContext.current - val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() - - val logCollector = remember { LogCollector(context) } - val savedLogs = remember { mutableStateListOf() } - - var isCollectingLogs by remember { mutableStateOf(false) } - var showTroubleshootingSteps by remember { mutableStateOf(false) } - var currentStep by remember { mutableIntStateOf(0) } - var logContent by remember { mutableStateOf("") } - var selectedLogFile by remember { mutableStateOf(null) } - var showDeleteDialog by remember { mutableStateOf(false) } - var showDeleteAllDialog by remember { mutableStateOf(false) } - var isLoadingLogContent by remember { mutableStateOf(false) } - var logContentLoaded by remember { mutableStateOf(false) } - - LaunchedEffect(isCollectingLogs) { - while (isCollectingLogs) { - delay(250) - delay(250) - } - } - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - var showBottomSheet by remember { mutableStateOf(false) } - - val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black - val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5) - val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD) - - var instructionText by remember { mutableStateOf("") } - val isDarkTheme = isSystemInDarkTheme() - - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - val logsDir = File(context.filesDir, "logs") - if (logsDir.exists()) { - savedLogs.clear() - savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") } - ?.sortedByDescending { it.lastModified() } ?: emptyList()) - } - } - } - - val saveLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("text/plain") - ) { uri -> - if (uri != null) { - coroutineScope.launch(Dispatchers.IO) { - try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(logContent.toByteArray()) - } - withContext(Dispatchers.Main) { - Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Failed to save log: ${e.localizedMessage}", - Toast.LENGTH_SHORT - ).show() - } - } - } - } - } - - LaunchedEffect(currentStep) { - instructionText = when (currentStep) { - 0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings." - 1 -> "Please put your AirPods in the case and close it, so they disconnect completely." - 2 -> "Preparing to collect logs... Please wait." - 3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done." - 4 -> "Log collection complete! You can now save or share the logs." - else -> "" - } - } - - fun openLogBottomSheet(file: File) { - selectedLogFile = file - logContent = "" - isLoadingLogContent = false - logContentLoaded = false - showBottomSheet = true - } - - val backdrop = rememberLayerBackdrop() - - Box( - modifier = Modifier.fillMaxSize() - ) { - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .verticalScroll(scrollState) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - Text( - text = stringResource(R.string.saved_logs), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - if (savedLogs.isEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.no_logs_found), - fontSize = 16.sp, - color = textColor - ) - } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Total Logs: ${savedLogs.size}", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - - if (savedLogs.size > 1) { - TextButton( - onClick = { showDeleteAllDialog = true }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Delete All") - } - } - } - - savedLogs.forEach { logFile -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clickable { - openLogBottomSheet(logFile) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = logFile.name, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US) - .format(Date(logFile.lastModified())), - fontSize = 14.sp, - color = textColor.copy(alpha = 0.6f) - ) - } - - CustomIconButton( - onClick = { - selectedLogFile = logFile - showDeleteDialog = true - } - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - AnimatedVisibility( - visible = !showTroubleshootingSteps, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(300)) - ) { - Button( - onClick = { showTroubleshootingSteps = true }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - enabled = !isCollectingLogs - ) { - Text(stringResource(R.string.collect_logs)) - } - } - - AnimatedVisibility( - visible = showTroubleshootingSteps, - enter = fadeIn(animationSpec = tween(300)) + - slideInVertically(animationSpec = tween(300)) { it / 2 }, - exit = fadeOut(animationSpec = tween(300)) + - slideOutVertically(animationSpec = tween(300)) { it / 2 } - ) { - Column { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.troubleshooting_steps), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(16.dp) - ) { - val textAlpha = animateFloatAsState( - targetValue = 1f, - animationSpec = tween(durationMillis = 300), - label = "textAlpha" - ) - - Text( - text = instructionText, - fontSize = 16.sp, - color = textColor.copy(alpha = textAlpha.value), - lineHeight = 22.sp - ) - - Spacer(modifier = Modifier.height(16.dp)) - - when (currentStep) { - 0 -> { - Button( - onClick = { - coroutineScope.launch { - logCollector.openXposedSettings(context) - delay(2000) - currentStep = 1 - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ) - ) { - Text("Open Xposed Settings") - } - } - - 1 -> { - Button( - onClick = { - currentStep = 2 - isCollectingLogs = true - - coroutineScope.launch { - try { - logCollector.clearLogs() - - logCollector.addLogMarker(LogCollector.LogMarkerType.START) - - logCollector.killBluetoothService() - - withContext(Dispatchers.Main) { - delay(500) - currentStep = 3 - } - - val timestamp = SimpleDateFormat( - "yyyyMMdd_HHmmss", - Locale.US - ).format(Date()) - - logContent = - logCollector.startLogCollection( - listener = { /* Removed live log display */ }, - connectionDetectedCallback = { - launch { - delay(5000) - withContext(Dispatchers.Main) { - if (isCollectingLogs) { - logCollector.stopLogCollection() - currentStep = 4 - isCollectingLogs = - false - } - } - } - } - ) - - val logFile = - logCollector.saveLogToInternalStorage( - "airpods_log_$timestamp.txt", - logContent - ) - logFile?.let { - withContext(Dispatchers.Main) { - savedLogs.add(0, it) - selectedLogFile = it - Toast.makeText( - context, - "Log saved: ${it.name}", - Toast.LENGTH_SHORT - ).show() - } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Error collecting logs: ${e.message}", - Toast.LENGTH_SHORT - ).show() - isCollectingLogs = false - currentStep = 0 - } - } - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ) - ) { - Text("Continue") - } - } - - 2, 3 -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = accentColor - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = if (currentStep == 2) "Preparing..." else "Collecting logs...", - fontSize = 14.sp, - color = textColor - ) - - if (currentStep == 3) { - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - coroutineScope.launch { - logCollector.addLogMarker( - LogCollector.LogMarkerType.CUSTOM, - "Manual stop requested by user" - ) - delay(1000) - logCollector.stopLogCollection() - delay(500) - - withContext(Dispatchers.Main) { - currentStep = 4 - isCollectingLogs = false - Toast.makeText( - context, - "Log collection stopped", - Toast.LENGTH_SHORT - ).show() - } - } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - modifier = Modifier - .fillMaxWidth() - ) { - Text("Stop Collection") - } - } - } - } - - 4 -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - Button( - onClick = { - selectedLogFile?.let { file -> - val fileUri = FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file - ) - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_STREAM, - fileUri - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser( - shareIntent, - "Share log file" - ) - ) - } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - modifier = Modifier.width(150.dp) - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share" - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Share") - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - onClick = { - selectedLogFile?.let { file -> - saveLauncher.launch( - file.absolutePath - ) - } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - modifier = Modifier.width(150.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_save), - contentDescription = "Save" - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Save") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - currentStep = 0 - showTroubleshootingSteps = false - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ) - ) { - Text("Done") - } - } - } - } - } - } - - if (showDeleteDialog && selectedLogFile != null) { - AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Log File") }, - text = { - Text("Are you sure you want to delete this log file? This action cannot be undone.") - }, - confirmButton = { - TextButton( - onClick = { - selectedLogFile?.let { file -> - if (file.delete()) { - savedLogs.remove(file) - Toast.makeText( - context, - "Log file deleted", - Toast.LENGTH_SHORT - ) - .show() - } else { - Toast.makeText( - context, - "Failed to delete log file", - Toast.LENGTH_SHORT - ).show() - } - } - showDeleteDialog = false - } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("Cancel") - } - } - ) - } - - if (showDeleteAllDialog) { - AlertDialog( - onDismissRequest = { showDeleteAllDialog = false }, - title = { Text("Delete All Logs") }, - text = { - Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.") - }, - confirmButton = { - TextButton( - onClick = { - coroutineScope.launch(Dispatchers.IO) { - var deletedCount = 0 - savedLogs.forEach { file -> - if (file.delete()) { - deletedCount++ - } - } - withContext(Dispatchers.Main) { - if (deletedCount > 0) { - savedLogs.clear() - Toast.makeText( - context, - "Deleted $deletedCount log files", - Toast.LENGTH_SHORT - ).show() - } else { - Toast.makeText( - context, - "Failed to delete log files", - Toast.LENGTH_SHORT - ).show() - } - } - } - showDeleteAllDialog = false - } - ) { - Text("Delete All", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteAllDialog = false }) { - Text("Cancel") - } - } - ) - } - - Spacer(modifier = Modifier.height(bottomPadding)) - } - - if (showBottomSheet) { - ModalBottomSheet( - onDismissRequest = { showBottomSheet = false }, - sheetState = sheetState, - containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7), - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - tonalElevation = 8.dp - ) { - LaunchedEffect(selectedLogFile) { - if (!logContentLoaded) { - delay(300) - withContext(Dispatchers.IO) { - isLoadingLogContent = true - logContent = try { - selectedLogFile?.readText() ?: "" - } catch (e: Exception) { - "Error loading log content: ${e.message}" - } - isLoadingLogContent = false - logContentLoaded = true - } - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .padding(bottom = 32.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - ) { - Text( - text = selectedLogFile?.name ?: "Log Content", - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - color = textColor - ) - Text( - text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US) - .format(Date(selectedLogFile?.lastModified() ?: 0)), - fontSize = 14.sp, - color = textColor.copy(alpha = 0.7f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - } - - if (isLoadingLogContent) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = accentColor) - } - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp) - .background( - color = Color.Black, - shape = RoundedCornerShape(8.dp) - ) - ) { - val horizontalScrollState = rememberScrollState() - val verticalScrollState = rememberScrollState() - - Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp) - .horizontalScroll(horizontalScrollState) - .verticalScroll(verticalScrollState) - ) { - Text( - text = logContent, - fontSize = 14.sp, - color = Color.LightGray, - lineHeight = 20.sp, - fontFamily = FontFamily.Monospace, - softWrap = false - ) - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = { - selectedLogFile?.let { file -> - val fileUri = FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - file - ) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, fileUri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser( - shareIntent, - "Share log file" - ) - ) - } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share" - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Share") - } - - Button( - onClick = { - selectedLogFile?.let { file -> - saveLauncher.launch(file.absolutePath) - } - }, - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = buttonBgColor, - contentColor = textColor - ), - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_save), - contentDescription = "Save" - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Save") - } - } - } - } - } - } - - DisposableEffect(Unit) { - onDispose { - logCollector.stopLogCollection() - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt deleted file mode 100644 index 514f3943a..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt +++ /dev/null @@ -1,332 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.Job -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.HearingAidSettings -import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.demoState - -private const val TAG = "UpdateHearingTestScreen" - -@Composable -fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - UpdateHearingTestScreen( - state = state, - topPadding = topPadding, - bottomPadding = bottomPadding, - setATTCharacteristicValue = viewModel::setATTCharacteristicValue - ) - } -} - -@Composable -fun UpdateHearingTestScreen( - state: AirPodsUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit -) { - val verticalScrollState = rememberScrollState() - - Column( - modifier = Modifier - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - - Text( - text = stringResource(R.string.hearing_test_value_instruction), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - ) - val tone = rememberSaveable { mutableFloatStateOf(0.5f) } - val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) } - val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } - val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } - val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } - val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) } - val leftEQ = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } - ) - ) { - mutableStateOf(FloatArray(8)) - } - val rightEQ = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } - ) - ) { - mutableStateOf(FloatArray(8)) - } - - val debounceJob = remember { mutableStateOf(null) } - val initialized = rememberSaveable { mutableStateOf(false) } - - val hearingAidSettings = remember { - mutableStateOf( - HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = leftAmplification.floatValue, - rightAmplification = rightAmplification.floatValue, - leftTone = tone.floatValue, - rightTone = tone.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReduction.floatValue, - rightAmbientNoiseReduction = ambientNoiseReduction.floatValue, - netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2, - balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, - ownVoiceAmplification = ownVoiceAmplification.floatValue - ) - ) - } - - LaunchedEffect(state.hearingAidData) { - val parsed = parseHearingAidSettingsResponse(state.hearingAidData) - if (parsed != null) { - leftEQ.value = parsed.leftEQ.copyOf() - rightEQ.value = parsed.rightEQ.copyOf() - conversationBoostEnabled.value = parsed.leftConversationBoost - tone.floatValue = parsed.leftTone - ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction - ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification - leftAmplification.floatValue = parsed.leftAmplification - rightAmplification.floatValue = parsed.rightAmplification - initialized.value = true - Log.d(TAG, "Updated hearing aid settings from notification") - } else { - Log.w(TAG, "Failed to parse hearing aid settings from notification") - } - } - - LaunchedEffect( - leftEQ.value, - rightEQ.value, - conversationBoostEnabled.value, - leftAmplification.floatValue, - rightAmplification.floatValue, - tone.floatValue, - ambientNoiseReduction.floatValue, - ownVoiceAmplification.floatValue - ) { - if (!initialized.value) return@LaunchedEffect - hearingAidSettings.value = HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = leftAmplification.floatValue, - rightAmplification = rightAmplification.floatValue, - leftTone = tone.floatValue, - rightTone = tone.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReduction.floatValue, - rightAmbientNoiseReduction = ambientNoiseReduction.floatValue, - netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2, - balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, - ownVoiceAmplification = ownVoiceAmplification.floatValue - ) - Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue) - } - - val frequencies = - listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.width(60.dp)) - Text( - text = stringResource(R.string.left), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMediumEmphasized - ) - Text( - text = stringResource(R.string.right), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMediumEmphasized - ) - } - - frequencies.forEachIndexed { index, freq -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = freq, - modifier = Modifier - .width(60.dp) - .align(Alignment.CenterVertically), - textAlign = TextAlign.End, - style = MaterialTheme.typography.labelSmall - ) - OutlinedTextField( - value = leftEQ.value[index].toString(), - onValueChange = { newValue -> - val parsed = newValue.toFloatOrNull() - if (parsed != null) { - val newArray = leftEQ.value.copyOf() - newArray[index] = parsed - leftEQ.value = newArray - Log.d(TAG, "Left EQ updated at index $index to $parsed") - } - }, -// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - textStyle = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontSize = 14.sp - ), - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = rightEQ.value[index].toString(), - onValueChange = { newValue -> - val parsed = newValue.toFloatOrNull() - if (parsed != null) { - val newArray = rightEQ.value.copyOf() - newArray[index] = parsed - rightEQ.value = newArray - Log.d(TAG, "Right EQ updated at index $index to $parsed") - } - }, -// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - textStyle = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontSize = 14.sp - ), - modifier = Modifier.weight(1f) - ) - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} - -@Preview(name = "Apple") -@Composable -fun UpdateHearingTestScreenPreviewApple() { - LibrePodsTheme( - m3eEnabled = false - ) { - Box ( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - ) { - UpdateHearingTestScreen( - state = demoState, - setATTCharacteristicValue = { _, _ -> } - ) - } - } -} - -@Preview(name = "Material") -@Composable -fun UpdateHearingTestScreenPreviewMaterial() { - LibrePodsTheme( - m3eEnabled = true - ) { - Box ( - modifier = Modifier - .wrapContentHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - UpdateHearingTestScreen( - state = demoState, - setATTCharacteristicValue = { _, _ -> } - ) - } - } -} - diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt deleted file mode 100644 index 0cfcda924..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem -import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel - -@Composable -fun VersionScreen(viewModel: AirPodsViewModel) { - val state by viewModel.uiState.collectAsState() - - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - StyledList(title = stringResource(R.string.version)) { - StyledListItem( - name = stringResource(R.string.version) + " 1", - description = state.version1, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.version) + " 2", - description = state.version2, - enabled = false - ) - - StyledListItem( - name = stringResource(R.string.version) + " 3", - description = state.version3, - enabled = false - ) - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt deleted file mode 100644 index 66125ea6c..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt +++ /dev/null @@ -1,57 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens.onboarding - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.AppInfoCard -import me.kavishdevar.librepods.presentation.components.DeviceInfoCard -import me.kavishdevar.librepods.presentation.components.StyledListItem - -@Composable -fun NotSupportedPage( - bypassCompatibilityCheck: () -> Unit -) { - val scrollState = rememberScrollState() - - Box( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(42.dp) - ) - ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = stringResource(R.string.check_the_repository_for_more_info), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = stringResource(R.string.enable_app_in_xposed_or_update_device), - style = MaterialTheme.typography.bodyMedium, - ) - DeviceInfoCard() - AppInfoCard() - - StyledListItem( - name = stringResource(R.string.bypass_compatibility_check), - onClick = bypassCompatibilityCheck - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt deleted file mode 100644 index ca6816c59..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt +++ /dev/null @@ -1,222 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens.onboarding - -import android.content.Context -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.carousel.HorizontalUncontainedCarousel -import androidx.compose.material3.carousel.rememberCarouselState -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme -import me.kavishdevar.librepods.utils.XposedState -import me.kavishdevar.librepods.utils.bypassDeviceCheck -import me.kavishdevar.librepods.utils.isSupported - -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) -@Composable -fun OnboardingScreen( - onOnboardingComplete: () -> Unit, -) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - val isSupported = isSupported(sharedPreferences) || XposedState.bluetoothScopeEnabled - - val state = rememberCarouselState( - initialItem = 0, - itemCount = { 4 } - ) - - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - val titles = listOf( - null, - stringResource(R.string.privacy_policy), - stringResource(R.string.not_supported), - stringResource(R.string.permissions), - ) - - val animationScope = rememberCoroutineScope() - - BackHandler { - animationScope.launch { - if (state.canScrollBackward) { - val targetItem = if (isSupported && state.currentItem == 3) 1 else state.currentItem - 1 - state.animateScrollToItem(targetItem) - } - } - } - - LibrePodsTheme( - m3eEnabled = true - ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - HorizontalUncontainedCarousel( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(12.dp), - state = state, - itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp, - userScrollEnabled = false - ) { index -> - val shape = rememberMaskShape(RoundedCornerShape(52.dp)) - Surface( - shape = shape, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(52.dp)) - ) { - Column( - modifier = Modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - titles[index]?.let { - Text( - text = it, - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - } - when (index) { - 0 -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Welcome to", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - ) - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.displayLargeEmphasized, - color = MaterialTheme.colorScheme.tertiary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.app_description), - style = MaterialTheme.typography.bodyMediumEmphasized, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(64.dp)) - FilledTonalIconButton( - onClick = { - animationScope.launch { - state.animateScrollToItem(1) - } - }, - modifier = Modifier - .minimumInteractiveComponentSize() - .size( - IconButtonDefaults.largeContainerSize( - IconButtonDefaults.IconButtonWidthOption.Wide - ) - ), - shape = IconButtonDefaults.largeRoundShape - ) { - Icon( - Icons.AutoMirrored.Default.ArrowForward, - contentDescription = "forward", - modifier = Modifier.size(IconButtonDefaults.largeIconSize), - ) - } - } - } - } - 1 -> { - PrivacyPolicyPage( - onForward = { - animationScope.launch { - if (isSupported) state.animateScrollToItem(3) else state.animateScrollToItem(2) - } - } - ) - } - 2 -> { - NotSupportedPage( - bypassCompatibilityCheck = { - bypassDeviceCheck(sharedPreferences) - } - ) - } - 3 -> { - PermissionsPage( - onBackward = { - animationScope.launch { - if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2) - } - }, - onForward = onOnboardingComplete - ) - } - } - } - } - } - Spacer(modifier = Modifier.height(bottomPadding)) - } - } -} - - -@OptIn(ExperimentalPermissionsApi::class) -@Preview -@Composable -fun OnboardingScreenPreview(){ - OnboardingScreen {} -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt deleted file mode 100644 index 8f428d0ba..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt +++ /dev/null @@ -1,345 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens.onboarding - -import android.content.Intent -import android.provider.Settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.Button -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.material3.toShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.accompanist.permissions.rememberPermissionState -import me.kavishdevar.librepods.presentation.MaterialIcons -import me.kavishdevar.librepods.presentation.components.ListItemOrientation -import me.kavishdevar.librepods.presentation.components.StyledList -import me.kavishdevar.librepods.presentation.components.StyledListItem - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun PermissionsPage( - onBackward: () -> Unit, - onForward: () -> Unit -) { - - var grantingAll = false - - val context = LocalContext.current - val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) } - - val phonePermissionState = rememberMultiplePermissionsState( - listOf( - "android.permission.READ_PHONE_STATE", - "android.permission.ANSWER_PHONE_CALLS" - ) - ) { - if (grantingAll) { - if (!canDrawOverlays.value) { - val intent = Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - "package:${context.packageName}".toUri() - ) - context.startActivity(intent) - } - } - } - - - val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") { - if (grantingAll) { - if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() - else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) - } - } - - - val bluetoothPermissionsState = rememberMultiplePermissionsState( - listOf( - "android.permission.BLUETOOTH_CONNECT", - "android.permission.BLUETOOTH_SCAN", - "android.permission.BLUETOOTH", - "android.permission.BLUETOOTH_ADMIN", - "android.permission.BLUETOOTH_ADVERTISE" - ) - ) { - if (grantingAll) { - if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() - else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() - else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) - } - } - - val lifecycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - canDrawOverlays.value = Settings.canDrawOverlays(context) - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - val scrollState = rememberScrollState() - - Box( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(42.dp) - ) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize() - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - StyledList(title = "Required Permissions") { - val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) - val animatedBluetoothContainerColor by animateColorAsState( - if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest - ) - - StyledListItem( - name = "Bluetooth", - onClick = if (!bluetoothPermissionsState.allPermissionsGranted) { - { - grantingAll = false - bluetoothPermissionsState.launchMultiplePermissionRequest() - } - } else null, - description = "Required to communicate with AirPods", - orientation = ListItemOrientation.Vertical, - leadingContent = { - Box( - modifier = Modifier - .size(48.dp) - .background( - animatedBluetoothContainerColor, - MaterialShapes.SoftBurst.normalized() - .toShape() - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.bluetooth, - contentDescription = "bluetooth", - modifier = Modifier.size(24.dp), - tint = animatedBluetoothIconColor - ) - } - }, - ) - } - StyledList(title = "Optional Permissions") { - val animatedNotificationsIconColor by animateColorAsState( - if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - ) - val animatedNotificationsContainerColor by animateColorAsState( - if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest - ) - val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) - val animatedPhoneContainerColor by animateColorAsState( - if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest - ) - - StyledListItem( - name = "Notifications", - onClick = if (!notificationPermissionState.status.isGranted) { - { - grantingAll = false - notificationPermissionState.launchPermissionRequest() - } - } else null, - description = "Show battery status", - orientation = ListItemOrientation.Vertical, - leadingContent = { - Box( - modifier = Modifier - .size(48.dp) - .background( - animatedNotificationsContainerColor, - MaterialShapes.SoftBurst.normalized() - .toShape() - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.notifications, - contentDescription = "notifications", - modifier = Modifier.size(24.dp), - tint = animatedNotificationsIconColor - ) - } - }, - ) - StyledListItem( - name = "Phone", - onClick = if (!phonePermissionState.allPermissionsGranted) { - { - grantingAll = false - phonePermissionState.launchMultiplePermissionRequest() - } - } else null, - description = "Respond to phone calls with head gestures", - orientation = ListItemOrientation.Vertical, - leadingContent = { - Box( - modifier = Modifier - .size(48.dp) - .background( - animatedPhoneContainerColor, - MaterialShapes.SoftBurst.normalized() - .toShape() - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.call, - contentDescription = "bluetooth", - modifier = Modifier.size(24.dp), - tint = animatedPhoneIconColor - ) - } - }, - ) - } - - val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) - val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest) - - StyledListItem( - name = "Display over other apps", - onClick = if (!canDrawOverlays.value) { - { - grantingAll = false - val intent = Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - "package:${context.packageName}".toUri() - ) - context.startActivity(intent) - } - } else null, - description = "Show popups when AirPods are nearby or audio switches to them.", - orientation = ListItemOrientation.Vertical, - leadingContent = { - Box( - modifier = Modifier - .size(48.dp) - .background( - animatedOverlayContainerColor, - MaterialShapes.SoftBurst.normalized() - .toShape() - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.stack, - contentDescription = "bluetooth", - modifier = Modifier.size(24.dp), - tint = animatedOverlayIconColor - ) - } - }, - ) - - Spacer(modifier = Modifier.weight(1f)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilledIconButton( - onClick = onBackward, - modifier = Modifier - .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), - shape = IconButtonDefaults.mediumRoundShape - ) { - Icon( - Icons.AutoMirrored.Default.ArrowBack, - contentDescription = "backward", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize), - ) - } - Button( - onClick = { - grantingAll = true - if (!bluetoothPermissionsState.allPermissionsGranted) bluetoothPermissionsState.launchMultiplePermissionRequest() - else if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() - else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() - else if (!canDrawOverlays.value) canDrawOverlays.value = - Settings.canDrawOverlays(context) - }, - modifier = Modifier - .height(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow).height) - .weight(1f), - enabled = !bluetoothPermissionsState.allPermissionsGranted || !notificationPermissionState.status.isGranted || !phonePermissionState.allPermissionsGranted || !canDrawOverlays.value - ) { - Text( - text = "Grant all", - style = MaterialTheme.typography.labelMedium - ) - } - FilledIconButton( - onClick = onForward, - modifier = Modifier - .minimumInteractiveComponentSize() - .size( - IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow) - ), - shape = IconButtonDefaults.mediumRoundShape, - enabled = bluetoothPermissionsState.allPermissionsGranted - ) { - Icon( - Icons.AutoMirrored.Default.ArrowForward, - contentDescription = "forward", - modifier = Modifier.size(IconButtonDefaults.mediumIconSize), - ) - } - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt deleted file mode 100644 index 23eaa8377..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt +++ /dev/null @@ -1,197 +0,0 @@ -package me.kavishdevar.librepods.presentation.screens.onboarding - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.R - -@Composable -fun PrivacyPolicyPage( - onForward: () -> Unit -) { - val scrollState = rememberScrollState() - - Box( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(42.dp) - ) - ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Last updated: 20 June 2026", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = "Overview", - style = MaterialTheme.typography.titleLarge - ) - - Text( - text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Third Party Services", - style = MaterialTheme.typography.titleLarge - ) - - Text( - text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Email", - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "You can edit or remove any of this information before sending the email.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Discord", - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "GitHub Issues", - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:", - style = MaterialTheme.typography.bodyMedium - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - "• LibrePods version name and version code", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "• Device manufacturer and model", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "• Android build information", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "• Installation source (Google Play or GitHub)", - style = MaterialTheme.typography.bodyMedium - ) - } - - Text( - text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Payments", style = MaterialTheme.typography.titleLarge - ) - - if (BuildConfig.PLAY_BUILD) { - Text( - text = "Google Play", style = MaterialTheme.typography.titleMedium - ) - - Text( - text = "When using the version available on Google Play, purchases are processed by Google Play.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.", - style = MaterialTheme.typography.bodyMedium - ) - } else { - Text( - text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium - ) - - Text( - text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.", - style = MaterialTheme.typography.bodyMedium - ) - } - - Text( - text = "Contact", style = MaterialTheme.typography.titleLarge - ) - - Text( - text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.", - style = MaterialTheme.typography.bodyMedium - ) - - Button( - onClick = onForward, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.i_agree), - style = MaterialTheme.typography.labelMediumEmphasized - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt deleted file mode 100644 index f2e44c1d3..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - - -package me.kavishdevar.librepods.presentation.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt deleted file mode 100644 index 1580e6ba0..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt +++ /dev/null @@ -1,12 +0,0 @@ -package me.kavishdevar.librepods.presentation.theme - -import androidx.compose.runtime.compositionLocalOf - -enum class DesignSystem { - Apple, - Material -} - -val LocalDesignSystem = compositionLocalOf { - DesignSystem.Apple -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt deleted file mode 100644 index 5d76d69ed..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialExpressiveTheme -import androidx.compose.material3.MotionScheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext - -val ColorScheme.sectionHeader: Color - get() = onBackground.copy(alpha = 0.6f) - -private val AppleDarkColorScheme = darkColorScheme( - surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF1C1C1E), - onSurface = Color(0xFFFFFFFF), - surfaceDim = Color(0x40888888), - primary = Color(0xFF0091FF), - secondaryContainer = Color(0xFF366AA8), - onSecondaryContainer = Color(0xFF0091FF), - onPrimary = Color(0xFFFFFFFF) -) - -private val AppleLightColorScheme = lightColorScheme( - surfaceContainer = Color(0xFFF2F2F7), - onBackground = Color(0xFF000000), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF000000), - surfaceDim = Color(0x40D9D9D9), - secondaryContainer = Color(0xFF6BC0FF), - onSecondaryContainer = Color(0xFF0088FF), - primary = Color(0xFF0088FF), - onPrimary = Color(0xFFFFFFFF) -) - -@Composable -fun LibrePodsTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - m3eEnabled: Boolean = false, - content: @Composable () -> Unit -) { - val colorScheme = when { - m3eEnabled -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> AppleDarkColorScheme - else -> AppleLightColorScheme - } - - CompositionLocalProvider( - LocalDesignSystem provides - if (m3eEnabled) DesignSystem.Material - else DesignSystem.Apple - ) { - MaterialExpressiveTheme( - colorScheme = colorScheme, - motionScheme = MotionScheme.expressive(), - typography = if (m3eEnabled) MaterialTypography else AppleTypography, - content = content - ) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt deleted file mode 100644 index a62b9fdd7..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt +++ /dev/null @@ -1,417 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.theme - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontVariation -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.googlefonts.GoogleFont -import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R - -val sfProFamily = FontFamily(Font(R.font.sf_pro)) - -val AppleTypography = Typography().run { - copy( - displayLarge = displayLarge.copy(fontFamily = sfProFamily), - displayMedium = displayMedium.copy(fontFamily = sfProFamily), - displaySmall = displaySmall.copy(fontFamily = sfProFamily), - - headlineLarge = headlineLarge.copy(fontFamily = sfProFamily), - headlineMedium = headlineMedium.copy(fontFamily = sfProFamily), - headlineSmall = headlineSmall.copy(fontFamily = sfProFamily), - - titleLarge = titleLarge.copy(fontFamily = sfProFamily), - titleMedium = titleMedium.copy(fontFamily = sfProFamily), - titleSmall = titleSmall.copy(fontFamily = sfProFamily), - - bodyLarge = bodyLarge.copy(fontFamily = sfProFamily), - bodyMedium = bodyMedium.copy( - fontFamily = sfProFamily, - fontSize = 16.sp - ), - bodySmall = bodySmall.copy( - fontFamily = sfProFamily, - fontSize = 14.sp, - lineHeight = 18.sp - ), - - labelLarge = labelLarge.copy(fontFamily = sfProFamily), - - labelMedium = labelMedium.copy( - fontFamily = sfProFamily, - fontSize = 16.sp, - ), - labelMediumEmphasized = labelMediumEmphasized.copy( - fontFamily = sfProFamily, - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ), - labelSmallEmphasized = labelSmallEmphasized.copy( - fontFamily = sfProFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) - ) -} - -val provider = GoogleFont.Provider( - providerAuthority = "com.google.android.gms.fonts", - providerPackage = "com.google.android.gms", - certificates = R.array.com_google_android_gms_fonts_certs -) - -private fun robotoFlex( - wght: Float = 400f, - slnt: Float = 0f, - grad: Float = 0f, - wdth: Float = 100f, - xtra: Float = 468f, - xopq: Float = 96f, - yopq: Float = 79f, -) = FontFamily( - androidx.compose.ui.text.googlefonts.Font( -// Font( -// resId = R.font.roboto_flex, - googleFont = GoogleFont("Roboto Flex"), - fontProvider = provider, - variationSettings = FontVariation.Settings( - FontVariation.Setting("wght", wght), - FontVariation.Setting("wdth", wdth), - FontVariation.Setting("slnt", slnt), - FontVariation.Setting("grad", grad), - FontVariation.Setting("xtra", xtra), - FontVariation.Setting("xopq", xopq), - FontVariation.Setting("yopq", yopq), - ) - ) -) - -val display = robotoFlex( - wght = 800f, - grad = 100f, - wdth = 100f -) - -val displayEmphasized = robotoFlex( - wght = 1000f, - slnt = -2f, - grad = 150f, - wdth = 150f, -) - -val body = robotoFlex() - -val bodyEmphasized = robotoFlex( - wght = 600f, - wdth = 130f, - grad = 75f, -) - -val label = robotoFlex( - wght = 450f, - grad = 50f -) - -val labelEmphasized = robotoFlex( - wght = 600f, - wdth = 140f, - grad = 75f -) - - -val MaterialTypography = Typography().run { - copy( - titleSmall = titleSmall.copy( - fontFamily = display, - fontSize = 24.sp, - lineHeight = 30.sp, - ), - - titleMedium = titleMedium.copy( - fontFamily = display, - fontSize = 28.sp, - lineHeight = 32.sp, - ), - - titleLarge = titleLarge.copy( - fontFamily = display, - fontSize = 32.sp, - lineHeight = 36.sp, - ), - - titleSmallEmphasized = titleSmallEmphasized.copy( - fontFamily = display, - fontSize = 24.sp, - lineHeight = 30.sp, - ), - - titleMediumEmphasized = titleMediumEmphasized.copy( - fontFamily = displayEmphasized, - fontSize = 28.sp, - lineHeight = 32.sp, - ), - - titleLargeEmphasized = titleLargeEmphasized.copy( - fontFamily = displayEmphasized, - fontSize = 32.sp, - lineHeight = 36.sp, - ), - - displaySmall = displaySmall.copy( - fontFamily = display, - fontSize = 32.sp, - lineHeight = 36.sp, - ), - - displayMedium = displayMedium.copy( - fontFamily = display, - fontSize = 36.sp, - lineHeight = 40.sp, - ), - - displayLarge = displayLarge.copy( - fontFamily = display, - fontSize = 40.sp, - lineHeight = 44.sp, - ), - - displaySmallEmphasized = displaySmallEmphasized.copy( - fontFamily = displayEmphasized, - fontSize = 38.sp, - lineHeight = 42.sp, - ), - - displayMediumEmphasized = displayMediumEmphasized.copy( - fontFamily = displayEmphasized, - fontSize = 42.sp, - lineHeight = 48.sp, - ), - - displayLargeEmphasized = displayLargeEmphasized.copy( - fontFamily = displayEmphasized, - fontSize = 48.sp, - lineHeight = 52.sp, - ), - - bodySmall = bodySmall.copy( - fontFamily = body, - fontSize = 14.sp, - lineHeight = 20.sp, - ), - - bodyMedium = bodyMedium.copy( - fontFamily = body, - fontSize = 16.sp, - lineHeight = 24.sp, - ), - - bodyLarge = bodyLarge.copy( - fontFamily = body, - fontSize = 18.sp, - lineHeight = 28.sp, - ), - - bodySmallEmphasized = bodySmallEmphasized.copy( - fontFamily = bodyEmphasized, - fontSize = 14.sp, - lineHeight = 20.sp, - ), - - bodyMediumEmphasized = bodyMediumEmphasized.copy( - fontFamily = bodyEmphasized, - fontSize = 16.sp, - lineHeight = 24.sp, - ), - - bodyLargeEmphasized = bodyLargeEmphasized.copy( - fontFamily = bodyEmphasized, - fontSize = 18.sp, - lineHeight = 28.sp, - ), - - labelSmall = labelSmall.copy( - fontFamily = label, - fontSize = 14.sp, - lineHeight = 18.sp, - ), - - labelMedium = labelMedium.copy( - fontFamily = label, - fontSize = 16.sp, - lineHeight = 20.sp, - ), - - labelLarge = labelLarge.copy( - fontFamily = label, - fontSize = 18.sp, - lineHeight = 22.sp, - ), - - labelSmallEmphasized = labelSmallEmphasized.copy( - fontFamily = labelEmphasized, - fontSize = 14.sp, - lineHeight = 18.sp, - ), - - labelMediumEmphasized = labelMediumEmphasized.copy( - fontFamily = labelEmphasized, - fontSize = 16.sp, - lineHeight = 20.sp, - ), - - labelLargeEmphasized = labelLargeEmphasized.copy( - fontFamily = labelEmphasized, - fontSize = 18.sp, - lineHeight = 22.sp, - ), - ) -} - -@Preview( - name = "Typography Showcase", - showBackground = true, - device = PIXEL_9_PRO_XL -) -@Composable -private fun TypographyPreview() { - LibrePodsTheme (m3eEnabled = true) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(28.dp)) - .padding(24.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - "Display Large", - style = MaterialTheme.typography.displayLarge - ) - - Text( - "Display Large Emphasized", - style = MaterialTheme.typography.displayLargeEmphasized - ) - - Text( - "Display Medium", - style = MaterialTheme.typography.displayMedium - ) - - Text( - "Display Medium Emphasized", - style = MaterialTheme.typography.displayMediumEmphasized - ) - - Text( - "Display Small", - style = MaterialTheme.typography.displaySmall - ) - - Text( - "Display Small Emphasized", - style = MaterialTheme.typography.displaySmallEmphasized - ) - - HorizontalDivider() - - Text( - "Body Large", - style = MaterialTheme.typography.bodyLarge - ) - - Text( - "Body Large Emphasized", - style = MaterialTheme.typography.bodyLargeEmphasized - ) - - Text( - "Body Medium", - style = MaterialTheme.typography.bodyMedium - ) - - Text( - "Body Medium Emphasized", - style = MaterialTheme.typography.bodyMediumEmphasized - ) - - Text( - "Body Small", - style = MaterialTheme.typography.bodySmall - ) - - Text( - "Body Small Emphasized", - style = MaterialTheme.typography.bodySmallEmphasized - ) - - HorizontalDivider() - - Text( - "Label Large", - style = MaterialTheme.typography.labelLarge - ) - - Text( - "Label Large Emphasized", - style = MaterialTheme.typography.labelLargeEmphasized - ) - - Text( - "Label Medium", - style = MaterialTheme.typography.labelMedium - ) - - Text( - "Label Medium Emphasized", - style = MaterialTheme.typography.labelMediumEmphasized - ) - - Text( - "Label Small", - style = MaterialTheme.typography.labelSmall - ) - - Text( - "Label Small Emphasized", - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt deleted file mode 100644 index 8c99178d6..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ /dev/null @@ -1,786 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.presentation.viewmodel - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.widget.Toast -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.content.edit -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.billing.BillingManager -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers -import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager -import me.kavishdevar.librepods.data.AirPodsInstance -import me.kavishdevar.librepods.data.AirPodsModels -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.Battery -import me.kavishdevar.librepods.data.BatteryComponent -import me.kavishdevar.librepods.data.BatteryStatus -import me.kavishdevar.librepods.data.Capability -import me.kavishdevar.librepods.data.ControlCommandRepository -import me.kavishdevar.librepods.data.CustomEq -import me.kavishdevar.librepods.data.StemAction -import me.kavishdevar.librepods.data.XposedRemotePrefProvider -import me.kavishdevar.librepods.services.AirPodsService - -@Suppress("ArrayInDataClass") -data class AirPodsUiState( - val deviceName: String = "AirPods", - - val isLocallyConnected: Boolean = false, - - val instance: AirPodsInstance? = null, - val capabilities: Set = emptySet(), - - val controlStates: Map = emptyMap(), - val offListeningMode: Boolean = true, - - val battery: List = emptyList(), - val ancMode: Int = 3, - - val modelName: String = "", - val actualModel: String = "", - val serialNumbers: List = emptyList(), - val version1: String = "", - val version2: String = "", - val version3: String = "", - - val headTrackingActive: Boolean = false, - val headGesturesEnabled: Boolean = true, - - val eqData: FloatArray = floatArrayOf(), - - val automaticEarDetectionEnabled: Boolean = true, - val automaticConnectionEnabled: Boolean = true, - - val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, - val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, - - val loudSoundReductionEnabled: Boolean = false, - val transparencyData: ByteArray = byteArrayOf(), - val hearingAidData: ByteArray = byteArrayOf(), - - val isPremium: Boolean = false, - val vendorIdHook: Boolean = false, - - val dynamicEndOfCharge: Boolean = false, - - val connectionSuccessful: Boolean = false, - val timeUntilFOSSPremiumExpiry: Long = 0L, - - val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled -) - -val demoInstance = AirPodsInstance( - name = "AirPods Pro", - model = AirPodsModels.getModelByModelNumber("A3064")!!, - actualModelNumber = "A3064", - serialNumber = "JXF9Q94A40", - leftSerialNumber = "L-DEMO", - rightSerialNumber = "R-DEMO", - version1 = "90.3388000000000000.1786", - version2 = "90.3388000000000000.1786", - version3 = "9441861", -) - -val demoState = AirPodsUiState( - deviceName = demoInstance.name, - - isLocallyConnected = true, - - capabilities = demoInstance.model.capabilities, - - battery = listOf( - Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING), - Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING), - Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING) - ), - - ancMode = 3, - offListeningMode = false, - - modelName = demoInstance.model.displayName, - actualModel = demoInstance.actualModelNumber, - serialNumbers = listOf( - demoInstance.serialNumber?: "", - demoInstance.leftSerialNumber?: "", - demoInstance.rightSerialNumber?: "" - ), - - version1 = demoInstance.version1?: "", - version2 = demoInstance.version2?: "", - version3 = demoInstance.version3?: "", - - headTrackingActive = true, - headGesturesEnabled = true, - - automaticEarDetectionEnabled = true, - automaticConnectionEnabled = true, - - leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES, - rightAction = StemAction.DIGITAL_ASSISTANT, - - loudSoundReductionEnabled = true, - - isPremium = true, - vendorIdHook = true, - - dynamicEndOfCharge = true, - - connectionSuccessful = true, - - customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70), - - controlStates = mapOf( - ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00), - ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00), - ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01), - ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03), - ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50), - ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02), - ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01), - ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02), - ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01), - ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45), - ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01), - ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01), - ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52), - ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01), - ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04) - ) -) - -class AirPodsViewModel( - -) : ViewModel() { - private lateinit var sharedPreferences: SharedPreferences - private lateinit var appContext: Context - private lateinit var service: AirPodsService - private lateinit var controlRepo: ControlCommandRepository - - var isReady by mutableStateOf(false) - private set - - fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) { - this.service = service - this.controlRepo = controlRepo - this.sharedPreferences = sharedPreferences - this.appContext = appContext - - observeBroadcasts() - loadName() - loadInstance() - loadSharedPreferences() - observeAACP() - loadCurrentStatus() - loadEq() - loadATT() - observeATT() - observeSharedPreferences() - observeBilling() - if (isDemoMode) activateDemoMode() - isReady = true - } - - private val _uiState = MutableStateFlow(AirPodsUiState()) - - val uiState: StateFlow = _uiState - - private var isDemoMode = false - - private val listeners = - mutableMapOf() - - private val xposedRemotePref = XposedRemotePrefProvider.create() - - private lateinit var broadcastReceiver: BroadcastReceiver - -// private val _cameraAction = MutableStateFlow( -// sharedPreferences.getString("camera_action", null) -// ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) -// -// val cameraAction: StateFlow = _cameraAction -// -// fun setCameraAction(action: AACPManager.Companion.StemPressType?) { -// sharedPreferences.edit { -// if (action == null) remove("camera_action") -// else putString("camera_action", action.name) -// } -// _cameraAction.value = action -// } - - fun setCustomEq(low: Int, mid: Int, high: Int) { - require(low in 0..100) - require(mid in 0..100) - require(high in 0..100) - val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high) - service.aacpManager.sendCustomEqPacket(updatedEq) - _uiState.update { - it.copy( - customEq = updatedEq - ) - } - } - - fun setCustomEqEnabled(enabled: Boolean) { - service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1)) - _uiState.update { - it.copy( - customEq = it.customEq.copy(state = if (enabled) 2 else 1) - ) - } - } - - override fun onCleared() { - listeners.forEach { (id, listener) -> - controlRepo.remove(id, listener) - } - service.aacpManager.customEqCallback = null - appContext.unregisterReceiver(broadcastReceiver) - } - - private fun loadName() { - val name = sharedPreferences.getString("name", "AirPods Pro")!! - _uiState.update { it.copy(deviceName = name) } - } - - private fun observeBilling() { - if (isDemoMode) return - viewModelScope.launch { - BillingManager.provider.isPremium.collect { premium -> - if (premium) { - sharedPreferences.edit { - remove("premium_expiry_time") - if (BuildConfig.PLAY_BUILD) remove("foss_upgraded") - } - _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } - } else { - if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) { - setControlCommandBoolean( - ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, - false - ) - setHeadGesturesEnabled(false) - _uiState.update { it.copy(isPremium = false) } - } - } - } - } - } - - private fun observeSharedPreferences() { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "name" -> loadName() - "off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd", - "head_gestures", "left_long_press_action", "right_long_press_action", - "dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences() - } - } - sharedPreferences.registerOnSharedPreferenceChangeListener(listener) - } - - private fun observeBroadcasts() { - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val action = intent?.action ?: return - if (!isDemoMode) when (action) { - AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> { - _uiState.update { - it.copy(isLocallyConnected = true) - } - } - - AirPodsNotifications.AIRPODS_DISCONNECTED -> { - _uiState.update { - it.copy(isLocallyConnected = false) - } - } - - AirPodsNotifications.BATTERY_DATA -> { - _uiState.update { - it.copy(battery = service.getBattery()) - } - } - - AirPodsNotifications.EQ_DATA -> { - val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf() - - _uiState.update { - it.copy(eqData = data) - } - } - - AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> { - loadInstance() - } - } - } - } - - val filter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) - addAction(AirPodsNotifications.BATTERY_DATA) - addAction(AirPodsNotifications.EQ_DATA) - addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED) - } - - appContext.registerReceiver( - broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED - ) - } - - fun setControlCommandValue( - identifier: ControlCommandIdentifiers, value: ByteArray - ) { - if (!isDemoMode) controlRepo.setValue(identifier, value) - _uiState.update { - it.copy( - controlStates = it.controlStates + (identifier to value) - ) - } - } - - fun setControlCommandBoolean( - identifier: ControlCommandIdentifiers, enabled: Boolean - ) { - setControlCommandValue( - identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - } - - fun setControlCommandInt( - identifier: ControlCommandIdentifiers, value: Int - ) { - setControlCommandValue(identifier, byteArrayOf(value.toByte())) - } - - fun setControlCommandByte( - identifier: ControlCommandIdentifiers, value: Byte - ) { - setControlCommandValue(identifier, byteArrayOf(value)) - } - - fun observeControl(identifier: ControlCommandIdentifiers) { - val listener = controlRepo.observe(identifier) { value -> - _uiState.update { state -> - val current = state.controlStates[identifier] - if (current?.contentEquals(value) == true) return@update state - - if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) { - state.copy( - dynamicEndOfCharge = value[0] == 0x01.toByte(), - controlStates = state.controlStates + (identifier to value) - ) - } else { - state.copy( - controlStates = state.controlStates + (identifier to value) - ) - } - } - } - - listeners[identifier] = listener - } - - // I'm lazy, sorry. - fun observeAACP() { - val identifiersList = listOf( - ControlCommandIdentifiers.MIC_MODE, - ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, - ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, - ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, - ControlCommandIdentifiers.ONE_BUD_ANC_MODE, - ControlCommandIdentifiers.LISTENING_MODE, - ControlCommandIdentifiers.AUTO_ANSWER_MODE, - ControlCommandIdentifiers.CHIME_VOLUME, - ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, - ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, - ControlCommandIdentifiers.VOLUME_SWIPE_MODE, - ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, - ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, - ControlCommandIdentifiers.HEARING_AID, - ControlCommandIdentifiers.AUTO_ANC_STRENGTH, - ControlCommandIdentifiers.HPS_GAIN_SWIPE, - ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, - ControlCommandIdentifiers.ALLOW_OFF_OPTION, - ControlCommandIdentifiers.STEM_CONFIG, - ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG, - ControlCommandIdentifiers.ALLOW_AUTO_CONNECT, - ControlCommandIdentifiers.EAR_DETECTION_CONFIG, - ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, - ControlCommandIdentifiers.OWNS_CONNECTION, - ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, - ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE - ) - for (identifier in identifiersList) { - observeControl(identifier) - } - service.aacpManager.customEqCallback = { customEq -> - _uiState.update { it.copy(customEq = customEq) } - } - } - - fun loadCurrentStatus() { - if (isDemoMode) return - service.let { service -> - _uiState.update { - it.copy( - isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, - battery = service.getBattery(), - ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, - controlStates = controlRepo.getMap() - ) - } - } - } - - private fun loadSharedPreferences() { - val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true) - val automaticEarDetectionEnabled = - sharedPreferences.getBoolean("automatic_ear_detection", true) - val automaticConnectionEnabled = - sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) - val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true) - val leftAction = StemAction.valueOf( - sharedPreferences.getString( - "left_long_press_action", - "CYCLE_NOISE_CONTROL_MODES" - ) ?: "CYCLE_NOISE_CONTROL_MODES" - ) - val rightAction = StemAction.valueOf( - sharedPreferences.getString( - "right_long_press_action", - "CYCLE_NOISE_CONTROL_MODES" - ) ?: "CYCLE_NOISE_CONTROL_MODES" - ) - val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) - val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false) - - val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) - - _uiState.update { - it.copy( - offListeningMode = offListeningModeEnabled, - automaticEarDetectionEnabled = automaticEarDetectionEnabled, - automaticConnectionEnabled = automaticConnectionEnabled, - headGesturesEnabled = headGesturesEnabled, - leftAction = leftAction, - rightAction = rightAction, - vendorIdHook = vendorIdHook, - dynamicEndOfCharge = dynamicEndOfCharge, - connectionSuccessful = connectionSuccessful, - ) - } - - // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase - if (BuildConfig.PLAY_BUILD) { - val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) - val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L) - val now = System.currentTimeMillis() - - when { - // existing temporary premium - expiryTime > 0L -> { - if (expiryTime <= now) { - sharedPreferences.edit { - remove("premium_expiry_time") - remove("foss_upgraded") - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = 0L, - isPremium = false - ) - } - } else { - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = expiryTime - now, - isPremium = true - ) - } - } - } - - // First migration from accidental FOSS Play build - fossUpgraded && !_uiState.value.isPremium -> { - val newExpiry = now + 28L * 24 * 60 * 60 * 1000 - - sharedPreferences.edit { - putLong("premium_expiry_time", newExpiry) - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = newExpiry - now, - isPremium = true - ) - } - } - } - } - } - - fun setOffListeningMode(enabled: Boolean) { - sharedPreferences.edit { putBoolean("off_listening_mode", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled) - _uiState.update { - it.copy(offListeningMode = enabled) - } - } - - fun setHeadGesturesEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("head_gestures", enabled) } - _uiState.update { - it.copy(headGesturesEnabled = enabled) - } - } - - fun setDynamicEndOfCharge(enabled: Boolean) { - service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled) - sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) } - _uiState.update { - it.copy(dynamicEndOfCharge = enabled) - } - } - - private fun loadEq() { - _uiState.update { - it.copy( - customEq = service.aacpManager.customEq - ) - } - } - - private fun loadInstance() { - val instance = service.airpodsInstance ?: AirPodsInstance( - name = "AirPods", - model = AirPodsModels.getModelByModelNumber("A3049")!!, - actualModelNumber = "A3049", - serialNumber = null, - leftSerialNumber = null, - rightSerialNumber = null, - version1 = null, - version2 = null, - version3 = null, - ) - - _uiState.update { - it.copy( - capabilities = instance.model.capabilities, - instance = instance, - modelName = instance.model.displayName, - actualModel = instance.actualModelNumber, - serialNumbers = listOf( - instance.serialNumber ?: "", - instance.leftSerialNumber ?: "", - instance.rightSerialNumber ?: "" - ), - version1 = instance.version1 ?: "", - version2 = instance.version2 ?: "", - version3 = instance.version3 ?: "" - ) - } - } - - fun reconnectFromSavedMac() { - service.reconnectFromSavedMac() - } - - fun setName(name: String) { - service.setName(name) - } - - fun startHeadTracking() { - service.startHeadTracking() - _uiState.update { it.copy(headTrackingActive = true) } - } - - fun stopHeadTracking() { - service.stopHeadTracking() - _uiState.update { it.copy(headTrackingActive = false) } - } - - fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { - when (handle) { - // ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction. - ATTHandles.LOUD_SOUND_REDUCTION -> { - _uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) - } - ATTHandles.HEARING_AID -> { - _uiState.value = _uiState.value.copy(hearingAidData = value) - } - ATTHandles.TRANSPARENCY -> { - _uiState.value = _uiState.value.copy(transparencyData = value) - } - } - viewModelScope.launch(Dispatchers.IO) { - try { - service.attManager.writeCharacteristic(handle, value) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun loadATT() { - val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf() - val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) { - loudSoundReduction[0].toInt() == 1 - } else false - val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf() - val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf() - _uiState.update { - it.copy( - loudSoundReductionEnabled = loudSoundReductionEnabled, - transparencyData = transparencyData, - hearingAidData = hearingAidData - ) - } - } - - fun observeATT() { - viewModelScope.launch(Dispatchers.IO) { - service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID) - service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) - } - service.attManager.setOnNotificationReceived { handle, value -> - when (handle) { - ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> { - val loudSoundReductionEnabled = if (value.isNotEmpty()) { - value[0].toInt() == 1 - } else false - _uiState.update { - it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled) - } - } - ATTHandles.HEARING_AID.value.toByte() -> { - _uiState.update { - it.copy(hearingAidData = value) - } - } - ATTHandles.TRANSPARENCY.value.toByte() -> { - _uiState.update { - it.copy(transparencyData = value) - } - } - } - } - } - - fun setAutomaticEarDetectionEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled) - _uiState.update { - it.copy( - automaticEarDetectionEnabled = enabled - ) - } - } - - fun setAutomaticConnectionEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled) - _uiState.update { - it.copy( - automaticConnectionEnabled = enabled - ) - } - } - - fun activateDemoMode() { - isDemoMode = true - _uiState.update {demoState} - } - - fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { - service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte) - } - - fun setLongPressAction(side: String, action: StemAction) { - val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action" - sharedPreferences.edit { putString(prefKey, action.name) } - _uiState.update { - if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action) - } - } - - private fun countEnabledModes(byteValue: Int): Int { - var count = 0 - if ((byteValue and 0x01) != 0) count++ - if ((byteValue and 0x02) != 0) count++ - if ((byteValue and 0x04) != 0) count++ - if ((byteValue and 0x08) != 0) count++ - return count - } - - fun toggleListeningMode(modeBit: Int) { - val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 - val newValue = if ((currentByte and modeBit) != 0) { - val temp = currentByte and modeBit.inv() - if (countEnabledModes(temp) >= 2) temp else currentByte - } else { - currentByte or modeBit - } - setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte()) - sharedPreferences.edit { putInt("long_press_byte", newValue) } - } - - fun disconnect() { - if (isDemoMode) { - isDemoMode = false - _uiState.update { - it.copy(isLocallyConnected = false) - } - } else { - service.disconnectAirPods() - if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - Toast.makeText( - appContext, "App has disconnected, disconnect from Android Settings.", - Toast.LENGTH_LONG - ).show() - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt deleted file mode 100644 index 2c1716c33..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt +++ /dev/null @@ -1,260 +0,0 @@ -package me.kavishdevar.librepods.presentation.viewmodel - -import android.app.Application -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.billing.BillingManager -import me.kavishdevar.librepods.data.XposedRemotePrefProvider -import kotlin.math.roundToInt - -data class AppSettingsUiState( - val showPhoneBatteryInWidget: Boolean = false, - val conversationalAwarenessPauseMusicEnabled: Boolean = false, - val relativeConversationalAwarenessVolumeEnabled: Boolean = true, - val disconnectWhenNotWearing: Boolean = false, - val takeoverWhenDisconnected: Boolean = false, - val takeoverWhenIdle: Boolean = false, - val takeoverWhenMusic: Boolean = false, - val takeoverWhenCall: Boolean = false, - val takeoverWhenRingingCall: Boolean = false, - val takeoverWhenMediaStart: Boolean = false, - val useAlternateHeadTrackingPackets: Boolean = true, - val conversationalAwarenessVolume: Float = 43f, - val showCameraDialog: Boolean = false, - val cameraPackageValue: String = "", - val cameraPackageError: String? = null, - val vendorIdHook: Boolean = false, - val isPremium: Boolean = false, - val connectionSuccessful: Boolean = false, - val showBottomSheetPopup: Boolean = true, - val showIslandPopup: Boolean = true, - val timeUntilFOSSPremiumExpiry: Long = 0L, - val m3eEnabled: Boolean = false -) - -class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { - private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE) - - private val _uiState = MutableStateFlow(AppSettingsUiState()) - val uiState = _uiState.asStateFlow() - - private val xposedRemotePref = XposedRemotePrefProvider.create() - - val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key -> - if (key == "connection_successful") { - _uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) } - } - } - - - init { - loadSettings() - observeBilling() - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener) - } - - override fun onCleared() { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) - } - - private fun observeBilling() { - viewModelScope.launch { - BillingManager.provider.isPremium.collect { premium -> - if (premium) { - sharedPreferences.edit { - remove("premium_expiry_time") - if (BuildConfig.PLAY_BUILD) remove("foss_upgraded") - } - _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } - } else { - // No billing premium, only update if no temporary premium is active - if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) { - _uiState.update { it.copy(isPremium = false) } - } - } - } - } - } - - private fun loadSettings() { - // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase - - val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) - val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L) - val now = System.currentTimeMillis() - - when { - // existing temporary premium - expiryTime > 0L -> { - if (expiryTime <= now) { - sharedPreferences.edit { - remove("premium_expiry_time") - remove("foss_upgraded") - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = 0L, - isPremium = false - ) - } - } else { - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = expiryTime - now, - isPremium = true - ) - } - } - } - - // First migration from accidental FOSS Play build - fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> { - val newExpiry = now + 28L * 24 * 60 * 60 * 1000 - - sharedPreferences.edit { - putLong("premium_expiry_time", newExpiry) - } - - _uiState.update { - it.copy( - timeUntilFOSSPremiumExpiry = newExpiry - now, - isPremium = true - ) - } - } - } - - _uiState.update { currentState -> - currentState.copy( - showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false), - conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false), - relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true), - disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), - takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false), - takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false), - takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), - takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false), - takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false), - takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false), - useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), - cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", - vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false), - connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false), - showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true), - showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true), - m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true) - ) - } - } - - fun setShowPhoneBatteryInWidget(enabled: Boolean) { - sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) } - _uiState.update { it.copy(showPhoneBatteryInWidget = enabled) } - } - - fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) } - _uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) } - } - - fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) } - _uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) } - } - - fun setDisconnectWhenNotWearing(enabled: Boolean) { - sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) } - _uiState.update { it.copy(disconnectWhenNotWearing = enabled) } - } - - fun setTakeoverWhenDisconnected(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) } - _uiState.update { it.copy(takeoverWhenDisconnected = enabled) } - } - - fun setTakeoverWhenIdle(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) } - _uiState.update { it.copy(takeoverWhenIdle = enabled) } - } - - fun setTakeoverWhenMusic(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_music", enabled) } - _uiState.update { it.copy(takeoverWhenMusic = enabled) } - } - - fun setTakeoverWhenCall(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_call", enabled) } - _uiState.update { it.copy(takeoverWhenCall = enabled) } - } - - fun setTakeoverWhenRingingCall(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) } - _uiState.update { it.copy(takeoverWhenRingingCall = enabled) } - } - - fun setTakeoverWhenMediaStart(enabled: Boolean) { - sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) } - _uiState.update { it.copy(takeoverWhenMediaStart = enabled) } - } - - fun setUseAlternateHeadTrackingPackets(enabled: Boolean) { - sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) } - _uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) } - } - - fun setConversationalAwarenessVolume(volume: Float) { - sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) } - _uiState.update { it.copy(conversationalAwarenessVolume = volume) } - } - - fun setShowCameraDialog(show: Boolean) { - _uiState.update { it.copy(showCameraDialog = show) } - } - - fun setCameraPackageValue(value: String) { - _uiState.update { it.copy(cameraPackageValue = value) } - } - - fun setCameraPackageError(error: String?) { - _uiState.update { it.copy(cameraPackageError = error) } - } - - fun saveCameraPackage() { - if (_uiState.value.cameraPackageValue.isBlank()) { - sharedPreferences.edit { remove("custom_camera_package") } - } else { - sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) } - } - setShowCameraDialog(false) - } - - fun setVendorIdHook(enabled: Boolean) { - xposedRemotePref.putBoolean("vendor_id_hook", enabled) - _uiState.update { it.copy(vendorIdHook = enabled) } - } - - fun setShowBottomSheetPopup(enabled: Boolean) { - sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) } - _uiState.update { it.copy(showBottomSheetPopup = enabled) } - } - - fun setShowIslandPopup(enabled: Boolean) { - sharedPreferences.edit { putBoolean("show_island_popup", enabled) } - _uiState.update { it.copy(showIslandPopup = enabled) } - } - - fun setm3eEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("m3e_enabled", enabled) } - _uiState.update { it.copy(m3eEnabled = enabled) } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt deleted file mode 100644 index b9e5235ec..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package me.kavishdevar.librepods.presentation.viewmodel - -import android.app.Activity -import android.app.Application -import android.content.Context -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import me.kavishdevar.librepods.billing.BillingManager - -data class PurchaseUiState( - val isPremium: Boolean = false, - val price: String = "" -) - -class PurchaseViewModel(application: Application) : AndroidViewModel(application) { - private val _uiState = MutableStateFlow(PurchaseUiState()) - val uiState = _uiState.asStateFlow() - - init { - observeBilling() - } - - private fun observeBilling() { - viewModelScope.launch { - BillingManager.provider.isPremium.collect { premium -> - _uiState.update { it.copy(isPremium = premium) } - } - } - viewModelScope.launch { - BillingManager.provider.price.collect { price -> - _uiState.update { it.copy(price = price) } - } - } - } - - fun purchase(context: Context) { - BillingManager.provider.purchase(context as Activity) - } - - fun restorePurchases() { - BillingManager.provider.restorePurchases() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt deleted file mode 100644 index 20a12d549..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.widgets - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import me.kavishdevar.librepods.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi - -class BatteryWidget : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - ServiceManager.getService()?.updateBattery() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt deleted file mode 100644 index 6253ccdf6..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.widgets - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.content.Intent -import android.util.Log -import android.widget.RemoteViews -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi - -class NoiseControlWidget : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - val views = RemoteViews(context.packageName, R.layout.noise_control_widget) - - val offIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 1) - } - val transparencyIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 3) - } - val adaptiveIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 4) - } - val ancIntent = Intent(context, NoiseControlWidget::class.java).apply { - action = "ACTION_SET_ANC_MODE" - putExtra("ANC_MODE", 2) - } - - views.setOnClickPendingIntent( - R.id.widget_off_button, - PendingIntent.getBroadcast(context, 0, offIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_transparency_button, - PendingIntent.getBroadcast(context, 1, transparencyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_adaptive_button, - PendingIntent.getBroadcast(context, 2, adaptiveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - views.setOnClickPendingIntent( - R.id.widget_anc_button, - PendingIntent.getBroadcast(context, 3, ancIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - ) - ServiceManager.getService()?.updateNoiseControlWidget() - appWidgetManager.updateAppWidget(appWidgetIds, views) - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action == "ACTION_SET_ANC_MODE") { - val mode = intent.getIntExtra("ANC_MODE", 1) - Log.d("NoiseControlWidget", "Setting ANC mode to $mode") - val service = ServiceManager.getService() - - if (service == null) { - Log.w("NoiseControlWidget", "Service unavailable") - return - } - - service.aacpManager - .sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - mode.toByte() - ) - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt deleted file mode 100644 index 180a7e953..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.receivers - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import kotlin.io.encoding.ExperimentalEncodingApi -import me.kavishdevar.librepods.services.AirPodsService - -class BootReceiver: BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - Intent.ACTION_MY_PACKAGE_REPLACED -> try { context?.startForegroundService( - Intent( - context, - AirPodsService::class.java - ) - ) } catch (e: Exception) { e.printStackTrace() } - Intent.ACTION_BOOT_COMPLETED -> try { context?.startForegroundService( - Intent( - context, - AirPodsService::class.java - ) - ) } catch (e: Exception) { e.printStackTrace() } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt deleted file mode 100644 index 8a7080163..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ /dev/null @@ -1,279 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.services - -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.graphics.drawable.Icon -import android.os.Build -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import android.util.Log -import androidx.annotation.RequiresApi -import me.kavishdevar.librepods.QuickSettingsDialogActivity -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.NoiseControlMode -import kotlin.io.encoding.ExperimentalEncodingApi - -@RequiresApi(Build.VERSION_CODES.Q) -class AirPodsQSService : TileService() { - - private lateinit var sharedPreferences: SharedPreferences - private var currentAncMode: Int = NoiseControlMode.OFF.ordinal + 1 - private var isAirPodsConnected: Boolean = false - - private val ancStatusReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == AirPodsNotifications.ANC_DATA) { - val newMode = intent.getIntExtra("data", NoiseControlMode.OFF.ordinal + 1) - Log.d("AirPodsQSService", "Received ANC update: $newMode") - currentAncMode = newMode - updateTile() - } - } - } - - private val availabilityReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - AirPodsNotifications.AIRPODS_CONNECTED -> { - Log.d("AirPodsQSService", "Received AIRPODS_CONNECTED") - isAirPodsConnected = true - currentAncMode = - ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) - updateTile() - } - AirPodsNotifications.AIRPODS_DISCONNECTED -> { - Log.d("AirPodsQSService", "Received AIRPODS_DISCONNECTED") - isAirPodsConnected = false - updateTile() - } - } - } - } - - private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == "off_listening_mode") { - Log.d("AirPodsQSService", "Preference changed: $key") - if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { - currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1 - } - updateTile() - } - } - - override fun onCreate() { - super.onCreate() - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - } - - @SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag") - override fun onStartListening() { - super.onStartListening() - Log.d("AirPodsQSService", "onStartListening") - - val service = ServiceManager.getService() - isAirPodsConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true - currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) - - if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { - currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1 - } - - val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA) - val availabilityIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) - } - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED) - registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(ancStatusReceiver, ancIntentFilter) - registerReceiver(availabilityReceiver, availabilityIntentFilter) - } - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - Log.d("AirPodsQSService", "Receivers registered") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error registering receivers: $e") - } - - updateTile() - } - - override fun onStopListening() { - super.onStopListening() - Log.d("AirPodsQSService", "onStopListening") - try { - unregisterReceiver(ancStatusReceiver) - unregisterReceiver(availabilityReceiver) - sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) - Log.d("AirPodsQSService", "Receivers unregistered") - } catch (e: IllegalArgumentException) { - Log.e("AirPodsQSService", "Receiver not registered or already unregistered: $e") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error unregistering receivers: $e") - } - } - - override fun onClick() { - super.onClick() - Log.d("AirPodsQSService", "onClick - Current state: $isAirPodsConnected, Current mode: $currentAncMode") - if (!isAirPodsConnected) { - Log.d("AirPodsQSService", "Tile clicked but AirPods not connected.") - return - } - - val clickBehavior = "cycle" // sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog" - - if (clickBehavior == "dialog") { - launchDialogActivity() - } else { - cycleAncMode() - } - } - - private fun launchDialogActivity() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, QuickSettingsDialogActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - startActivityAndCollapse(pendingIntent) - } else { - val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - } - @Suppress("DEPRECATION") - @SuppressLint("StartActivityAndCollapseDeprecated") - startActivityAndCollapse(intent) - } - Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error launching QuickSettingsDialogActivity: $e") - } - } - - private fun cycleAncMode() { - val service = ServiceManager.getService() - if (service == null) { - Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.") - return - } - val nextMode = getNextAncMode() - Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode") - service.aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - nextMode - ) - } - - private fun updateTile() { - val tile = qsTile ?: return - Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode") - - val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods" - - if (isAirPodsConnected) { - tile.state = Tile.STATE_ACTIVE - tile.label = getModeLabel(currentAncMode) - tile.subtitle = deviceName - tile.icon = Icon.createWithResource(this, getModeIcon(currentAncMode)) - } else { - tile.state = Tile.STATE_UNAVAILABLE - tile.label = "AirPods" - tile.subtitle = "Disconnected" - tile.icon = Icon.createWithResource(this, R.drawable.airpods) - } - - try { - tile.updateTile() - Log.d("AirPodsQSService", "Tile updated successfully") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error updating tile: $e") - } - } - - private fun isOffModeEnabled(): Boolean { - return sharedPreferences.getBoolean("off_listening_mode", true) - } - - private fun getAvailableModes(): List { - val modes = mutableListOf( - NoiseControlMode.TRANSPARENCY.ordinal + 1, - NoiseControlMode.ADAPTIVE.ordinal + 1, - NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 - ) - if (isOffModeEnabled()) { - modes.add(0, NoiseControlMode.OFF.ordinal + 1) - } - return modes - } - - private fun getNextAncMode(): Int { - val availableModes = getAvailableModes() - Log.d("AirPodsQSService", "availableModes: $availableModes, currentAncMode: $currentAncMode") - val currentIndex = availableModes.indexOf(currentAncMode) - val nextIndex = (currentIndex + 1) % availableModes.size - Log.d("AirPodsQSService", "nextIndex: $nextIndex") - return availableModes[nextIndex] - } - - private fun getModeLabel(mode: Int): String { - return when (mode) { - NoiseControlMode.OFF.ordinal + 1 -> "Off" - NoiseControlMode.TRANSPARENCY.ordinal + 1 -> "Transparency" - NoiseControlMode.ADAPTIVE.ordinal + 1 -> "Adaptive" - NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> "Noise Cancellation" - else -> "Unknown" - } - } - - private fun getModeIcon(mode: Int): Int { - return when (mode) { - NoiseControlMode.OFF.ordinal + 1 -> R.drawable.noise_cancellation - NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.transparency - NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.adaptive - NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.noise_cancellation - else -> R.drawable.airpods - } - } - - override fun onTileAdded() { - super.onTileAdded() - Log.d("AirPodsQSService", "Tile added") - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt deleted file mode 100644 index 0cf08c11d..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ /dev/null @@ -1,3218 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.services - -//import me.kavishdevar.librepods.utils.CrossDevice -//import me.kavishdevar.librepods.utils.CrossDevicePackets -import android.Manifest -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.appwidget.AppWidgetManager -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothHeadset -import android.bluetooth.BluetoothManager -import android.bluetooth.BluetoothProfile -import android.bluetooth.BluetoothSocket -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.content.res.Resources -import android.graphics.Color -import android.media.AudioManager -import android.net.Uri -import android.os.BatteryManager -import android.os.Binder -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.ParcelUuid -import android.os.UserHandle -import android.provider.Settings -import android.telecom.TelecomManager -import android.telephony.TelephonyCallback -import android.telephony.TelephonyManager -import android.util.Log -import android.util.TypedValue -import android.view.View -import android.widget.RemoteViews -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.core.app.NotificationCompat -import androidx.core.content.edit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import me.kavishdevar.librepods.BuildConfig -import me.kavishdevar.librepods.MainActivity -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.bluetooth.AACPManager -import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType -import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.bluetooth.ATTManagerv2 -import me.kavishdevar.librepods.bluetooth.BLEManager -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager -import me.kavishdevar.librepods.bluetooth.createBluetoothSocket -import me.kavishdevar.librepods.data.AirPodsInstance -import me.kavishdevar.librepods.data.AirPodsModels -import me.kavishdevar.librepods.data.AirPodsNotifications -import me.kavishdevar.librepods.data.Battery -import me.kavishdevar.librepods.data.BatteryComponent -import me.kavishdevar.librepods.data.BatteryStatus -import me.kavishdevar.librepods.data.Capability -import me.kavishdevar.librepods.data.CustomEq -import me.kavishdevar.librepods.data.StemAction -import me.kavishdevar.librepods.data.XposedRemotePrefProvider -import me.kavishdevar.librepods.data.isHeadTrackingData -import me.kavishdevar.librepods.presentation.overlays.IslandType -import me.kavishdevar.librepods.presentation.overlays.IslandWindow -import me.kavishdevar.librepods.presentation.overlays.PopupWindow -import me.kavishdevar.librepods.presentation.widgets.BatteryWidget -import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget -import me.kavishdevar.librepods.utils.GestureDetector -import me.kavishdevar.librepods.utils.HeadTracking -import me.kavishdevar.librepods.utils.MediaController -import me.kavishdevar.librepods.utils.SystemApisUtils -import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_DEVICE_TYPE -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MAIN_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MANUFACTURER_NAME -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_MODEL_NAME -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_BATTERY -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON -import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.time.Duration.Companion.milliseconds - -private const val TAG = "AirPodsService" - -object ServiceManager { - private var service: AirPodsService? = null - - @Synchronized - fun getService(): AirPodsService? { - return service - } - - @Synchronized - fun setService(service: AirPodsService?) { - this.service = service - } -} - -// @Suppress("unused") -class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { - var macAddress = "" - var localMac = "" - lateinit var aacpManager: AACPManager - lateinit var attManager: ATTManagerv2 - var airpodsInstance: AirPodsInstance? = null - var cameraActive = false - private var disconnectedBecauseReversed = false - private var otherDeviceTookOver = false - - data class ServiceConfig( - var deviceName: String = "AirPods", - var earDetectionEnabled: Boolean = true, - var conversationalAwarenessPauseMusic: Boolean = false, - var showPhoneBatteryInWidget: Boolean = true, - var relativeConversationalAwarenessVolume: Boolean = true, - var headGestures: Boolean = true, - var disconnectWhenNotWearing: Boolean = false, - var conversationalAwarenessVolume: Int = 43, - var qsClickBehavior: String = "cycle", - var bleOnlyMode: Boolean = false, - - // AirPods state-based takeover - var takeoverWhenDisconnected: Boolean = true, - var takeoverWhenIdle: Boolean = true, - var takeoverWhenMusic: Boolean = false, - var takeoverWhenCall: Boolean = true, - - // Phone state-based takeover - var takeoverWhenRingingCall: Boolean = true, - var takeoverWhenMediaStart: Boolean = true, - - var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, - var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!, - - var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, - var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!, - - var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, - var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!, - - var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, - var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, - - var cameraAction: StemPressType? = null, - - // AirPods device information - var airpodsName: String = "", - var airpodsModelNumber: String = "", - var airpodsManufacturer: String = "", - var airpodsSerialNumber: String = "", - var airpodsLeftSerialNumber: String = "", - var airpodsRightSerialNumber: String = "", - var airpodsVersion1: String = "", - var airpodsVersion2: String = "", - var airpodsVersion3: String = "", - var airpodsHardwareRevision: String = "", - var airpodsUpdaterIdentifier: String = "", - - // phone's mac, needed for tipi - var selfMacAddress: String = "" - ) - - private lateinit var config: ServiceConfig - - inner class LocalBinder : Binder() { - fun getService(): AirPodsService = this@AirPodsService - } - - private lateinit var sharedPreferencesLogs: SharedPreferences - private lateinit var sharedPreferences: SharedPreferences - private val packetLogKey = "packet_log" - private val _packetLogsFlow = MutableStateFlow>(emptySet()) - val packetLogsFlow: StateFlow> get() = _packetLogsFlow - - private lateinit var telephonyManager: TelephonyManager - private lateinit var phoneStateListener: TelephonyCallback - private val maxLogEntries = 1000 - private val inMemoryLogs = mutableSetOf() - - private var handleIncomingCallOnceConnected = false - - lateinit var bleManager: BLEManager - - companion object { - init { - System.loadLibrary("bluetooth_socket") - } - } - - private val bleStatusListener = object : BLEManager.AirPodsStatusListener { - @SuppressLint("NewApi") - override fun onDeviceStatusChanged( - device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? - ) { - if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast - Log.d(TAG, "Seems no device has taken over, we will.") - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - val bluetoothDevice = bluetoothAdapter.getRemoteDevice( - sharedPreferences.getString( - "mac_address", "" - ) ?: "" - ) - connectToSocket(bluetoothAdapter, bluetoothDevice) - } - Log.d(TAG, "Device status changed") - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - updateBattery() - } - - override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { - Log.d(TAG, "New address detected") - } - - override fun onLidStateChanged( - lidOpen: Boolean, - ) { - if (lidOpen) { - Log.d(TAG, "Lid opened") - showPopup( - this@AirPodsService, - getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") - ?: "AirPods" - ) - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - sendBatteryBroadcast() - } else { - Log.d(TAG, "Lid closed") - } - } - - override fun onEarStateChanged( - device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean - ) { - Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar") - - // In BLE-only mode, ear detection is purely based on BLE data - if (config.bleOnlyMode) { - Log.d(TAG, "BLE-only mode: ear detection from BLE data") - } - } - - override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return - val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 - val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 - val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 - val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging - val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging - val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging - - batteryNotification.setBatteryDirect( - leftLevel = leftLevel, - leftCharging = leftCharging == true, - rightLevel = rightLevel, - rightCharging = rightCharging == true, - caseLevel = caseLevel, - caseCharging = caseCharging == true - ) - updateBattery() - Log.d(TAG, "Battery changed") - } - - override fun onDeviceDisappeared() { - Log.d(TAG, "All disappeared") - updateNotificationContent( - false - ) - } - } - - fun isBluetoothSocketExempted(): Boolean { - return try { - BluetoothSocket::class.java.declaredConstructors // will throw if still blocked - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - - - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds") - override fun onCreate() { - super.onCreate() - Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") - - sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) - - inMemoryLogs.addAll( - sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet() - ) - _packetLogsFlow.value = inMemoryLogs.toSet() - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - initializeConfig() - - aacpManager = AACPManager() - initializeAACPManagerCallback() - - attManager = ATTManagerv2() - - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - - localMac = config.selfMacAddress - if (localMac.isEmpty()) { - if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - localMac = bluetoothAdapter.address - } else { - localMac = try { - val process = Runtime.getRuntime().exec( - arrayOf("su", "-c", "settings get secure bluetooth_address") - ) - - val exitCode = process.waitFor() - - if (exitCode == 0) { - process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() } - } else { - "" - } - } catch (e: Exception) { - Log.e( - TAG, - "Error retrieving local MAC address: ${e.message}. We probably aren't rooted." - ) - "" - } - } - config.selfMacAddress = localMac - sharedPreferences.edit { - putString("self_mac_address", localMac) - } - } - - ServiceManager.setService(this) - startForegroundNotification() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - initGestureDetector() - } else { - gestureDetector = null - config.headGestures = false - sharedPreferences.edit { putBoolean("head_gestures", false) } - Log.d(TAG, "Head gestures disabled as device is running Android 9 or below") - } - - bleManager = BLEManager(this) - bleManager.setAirPodsStatusListener(bleStatusListener) - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - - with(sharedPreferences) { - edit { - if (!contains("conversational_awareness_pause_music")) putBoolean( - "conversational_awareness_pause_music", false - ) - if (!contains("personalized_volume")) putBoolean("personalized_volume", false) - if (!contains("automatic_ear_detection")) putBoolean( - "automatic_ear_detection", true - ) - if (!contains("long_press_nc")) putBoolean("long_press_nc", true) - if (!contains("show_phone_battery_in_widget")) putBoolean( - "show_phone_battery_in_widget", true - ) - if (!contains("single_anc")) putBoolean("single_anc", true) - if (!contains("long_press_transparency")) putBoolean( - "long_press_transparency", true - ) - if (!contains("conversational_awareness")) putBoolean( - "conversational_awareness", true - ) - if (!contains("relative_conversational_awareness_volume")) putBoolean( - "relative_conversational_awareness_volume", true - ) - if (!contains("long_press_adaptive")) putBoolean("long_press_adaptive", true) - if (!contains("loud_sound_reduction")) putBoolean("loud_sound_reduction", true) - if (!contains("long_press_off")) putBoolean("long_press_off", false) - if (!contains("volume_control")) putBoolean("volume_control", true) - if (!contains("head_gestures")) putBoolean("head_gestures", true) - if (!contains("disconnect_when_not_wearing")) putBoolean( - "disconnect_when_not_wearing", false - ) - - // AirPods state-based takeover - if (!contains("takeover_when_disconnected")) putBoolean( - "takeover_when_disconnected", false - ) - if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", false) - if (!contains("takeover_when_music")) putBoolean("takeover_when_music", false) - if (!contains("takeover_when_call")) putBoolean("takeover_when_call", false) - - // Phone state-based takeover - if (!contains("takeover_when_ringing_call")) putBoolean( - "takeover_when_ringing_call", false - ) - if (!contains("takeover_when_media_start")) putBoolean( - "takeover_when_media_start", false - ) - - if (!contains("adaptive_strength")) putInt("adaptive_strength", 51) - if (!contains("tone_volume")) putInt("tone_volume", 75) - if (!contains("conversational_awareness_volume")) putInt( - "conversational_awareness_volume", 43 - ) - - if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") - if (!contains("name")) putString("name", "AirPods") - - if (!contains("left_single_press_action")) putString( - "left_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name - ) - if (!contains("right_single_press_action")) putString( - "right_single_press_action", - StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name - ) - if (!contains("left_double_press_action")) putString( - "left_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name - ) - if (!contains("right_double_press_action")) putString( - "right_double_press_action", - StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name - ) - if (!contains("left_triple_press_action")) putString( - "left_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name - ) - if (!contains("right_triple_press_action")) putString( - "right_triple_press_action", - StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name - ) - if (!contains("left_long_press_action")) putString( - "left_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name - ) - if (!contains("right_long_press_action")) putString( - "right_long_press_action", - StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name - ) - if (!contains("camera_action")) putString("camera_action", "SINGLE_PRESS") - - } - } - - initializeConfig() - - externalBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") { - if (intent.hasExtra("mode")) { - val mode = intent.getIntExtra("mode", -1) - if (mode in 1..4) { - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - mode - ) - } - } else { - val currentMode = ancNotification.status - val configByte = sharedPreferences.getInt("long_press_byte", 0b0111) - val allowOffModeValue = - aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } - val allowOffMode = - allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) - val nextMode = getNextMode(currentMode = currentMode, configByte = configByte, allowOffMode) - - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - nextMode - ) - Log.d( - TAG, - "Cycling ANC mode from $currentMode to $nextMode" - ) - } - } else if (intent?.action == "me.kavishdevar.librepods.CONVO_DETECT") { - if (intent.hasExtra("enabled")) { - val enabled = intent.getBooleanExtra("enabled", false) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, - enabled - ) - } - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(externalBroadcastReceiver, externalBroadcastFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - externalBroadcastReceiver, externalBroadcastFilter - ) - } - val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - MediaController.initialize( - audioManager, this@AirPodsService.getSharedPreferences( - "settings", MODE_PRIVATE - ) - ) -// Log.d(TAG, "Initializing CrossDevice") -// CoroutineScope(Dispatchers.IO).launch { -// CrossDevice.init(this@AirPodsService) -// Log.d(TAG, "CrossDevice initialized") -// } - - sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) - macAddress = sharedPreferences.getString("mac_address", "") ?: "" - - telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener { - override fun onCallStateChanged(state: Int) { - when (state) { - TelephonyManager.CALL_STATE_RINGING -> { - val leAvailableForAudio = - bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true -// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { - if (leAvailableForAudio) runBlocking { - takeOver("call") - } - if (config.headGestures) { - handleIncomingCall() - } - } - - TelephonyManager.CALL_STATE_OFFHOOK -> { - val leAvailableForAudio = - bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true -// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( - if (leAvailableForAudio) CoroutineScope( - Dispatchers.IO - ).launch { - takeOver("call") - } - isInCall = true - } - - TelephonyManager.CALL_STATE_IDLE -> { - isInCall = false - gestureDetector?.stopDetection() - } - } - } - } - if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) - } - - if (config.showPhoneBatteryInWidget) { - widgetMobileBatteryEnabled = true - val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) - batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter, RECEIVER_EXPORTED - ) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter - ) - } - } - val serviceIntentFilter = IntentFilter().apply { - addAction("android.bluetooth.device.action.ACL_CONNECTED") - addAction("android.bluetooth.device.action.ACL_DISCONNECTED") - addAction("android.bluetooth.device.action.BOND_STATE_CHANGED") - addAction("android.bluetooth.device.action.NAME_CHANGED") - addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.adapter.action.STATE_CHANGED") - addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT") - addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") - addAction("android.bluetooth.device.action.UUID") - } - - connectionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) { - device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra("device", BluetoothDevice::class.java)!! - } else { - intent.getParcelableExtra("device") as BluetoothDevice? - } - - if (config.deviceName == "AirPods" && device?.name != null) { - config.deviceName = device?.name ?: "AirPods" - sharedPreferences.edit { putString("name", config.deviceName) } - } - -// Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) -// if (!CrossDevice.isAvailable) { - Log.d(TAG, "${config.deviceName} connected") - CoroutineScope(Dispatchers.IO).launch { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - connectToSocket(bluetoothManager.adapter, device!!) - } - Log.d(TAG, "Setting metadata") - setMetadatas(device!!) -// isConnectedLocally = true - macAddress = device!!.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } -// } - - } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { - device = null -// isConnectedLocally = false - popupShown = false - updateNotificationContent(false) - aacpManager.disconnected() - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null - } - } - } - val showIslandReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { - showIsland( - this@AirPodsService, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ) - ) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - val showIslandIntentFilter = IntentFilter().apply { - addAction("me.kavishdevar.librepods.cross_device_island") - addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - showIslandReceiver, showIslandIntentFilter - ) - } - - val deviceIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED) - registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - connectionReceiver, deviceIntentFilter - ) - registerReceiver(bluetoothReceiver, serviceIntentFilter) - } - - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - - bluetoothAdapter.bondedDevices.forEach { device -> - device.fetchUuidsWithSdp() - if (device.uuids != null) { - if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { - bluetoothAdapter.getProfileProxy( - this, object : BluetoothProfile.ServiceListener { - @SuppressLint("NewApi") - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { -// if (!CrossDevice.isAvailable) { - CoroutineScope(Dispatchers.IO).launch { - connectToSocket(bluetoothAdapter, device) - } - setMetadatas(device) - macAddress = device.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } -// } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { - setPackage(packageName) - }) - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP - ) - } - } - } - -// if (!isConnectedLocally && !CrossDevice.isAvailable) { -// clearPacketLogs() -// } - - CoroutineScope(Dispatchers.IO).launch { - bleManager.startScanning() - } - } - - @Suppress("unused") - fun cameraOpened() { - Log.d(TAG, "Camera opened, gonna handle stem presses and take action if visible") - cameraActive = true - setupStemActions() - } - - @Suppress("unused") - fun cameraClosed() { - cameraActive = false - setupStemActions() - } - - fun isCustomAction( - action: StemAction?, default: StemAction? - ): Boolean { - return action != default - } - - fun setupStemActions() { - val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS] - val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS] - val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] - val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] - - val singlePressCustomized = - isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction( - config.rightSinglePressAction, singlePressDefault - ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) - val doublePressCustomized = - isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction( - config.rightDoublePressAction, doublePressDefault - ) - val triplePressCustomized = - isCustomAction(config.leftTriplePressAction, triplePressDefault) || isCustomAction( - config.rightTriplePressAction, triplePressDefault - ) - val longPressCustomized = isCustomAction( - config.leftLongPressAction, longPressDefault - ) || isCustomAction( - config.rightLongPressAction, longPressDefault - ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) - Log.d( - TAG, - "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized" - ) - aacpManager.sendStemConfigPacket( - singlePressCustomized, - doublePressCustomized, - triplePressCustomized, - longPressCustomized, - ) - } - - @ExperimentalEncodingApi - private fun initializeAACPManagerCallback() { - aacpManager.setPacketCallback(object : AACPManager.PacketCallback { - @SuppressLint("MissingPermission") - override fun onBatteryInfoReceived(batteryInfo: ByteArray) { - batteryNotification.setBattery(batteryInfo) - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) - setPackage(packageName) - }) - updateBattery() - updateNotificationContent( - true, - this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) - .getString("name", device?.name), - batteryNotification.getBattery() - ) -// CrossDevice.sendRemotePacket(batteryInfo) -// CrossDevice.batteryBytes = batteryInfo - - for (battery in batteryNotification.getBattery()) { - Log.d( - "AirPodsParser", - "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% " - ) - } - - if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { - disconnectAudio(this@AirPodsService, device) - } else { - connectAudio(this@AirPodsService, device) - } - } - - override fun onEarDetectionReceived(earDetection: ByteArray) { - sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { - val list = earDetectionNotification.status - val bytes = ByteArray(2) - bytes[0] = list[0] - bytes[1] = list[1] - putExtra("data", bytes) - }.apply { - setPackage(packageName) - }) - Log.d( - "AirPodsParser", - "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" - ) - processEarDetectionChange(earDetection) - } - - override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { - conversationAwarenessNotification.setData(conversationAwareness) - sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { - putExtra("data", conversationAwarenessNotification.status) - }.apply { - setPackage(packageName) - }) - - if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - MediaController.startSpeaking() - } else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() - } - - Log.d( - "AirPodsParser", - "Conversation Awareness: ${conversationAwarenessNotification.status}" - ) - } - - override fun onControlCommandReceived(controlCommand: ByteArray) { - val command = AACPManager.ControlCommand.fromByteArray(controlCommand) - if (command.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value) { - ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() } - ?.get(0) ?: 0x00.toByte())) - sendANCBroadcast() - updateNoiseControlWidget() - } - } - - override fun onOwnershipChangeReceived(owns: Boolean) { - if (!owns) { - MediaController.recentlyLostOwnership = true - Handler(Looper.getMainLooper()).postDelayed({ - MediaController.recentlyLostOwnership = false - }, 3000) - Log.d(TAG, "ownership lost") - MediaController.sendPause() - MediaController.pausedForOtherDevice = true - otherDeviceTookOver = true - disconnectAudio( - this@AirPodsService, device - ) - } - } - - override fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) { - // TODO: Show a reverse button, but that's a lot of effort -- i'd have to change the UI too, which i hate doing, and handle other device's reverses too, and disconnect audio etc... so for now, just pause the audio and show the island without asking to reverse. - // handling reverse is a problem because we'd have to disconnect the audio, but there's no option connect audio again natively, so notification would have to be changed. I wish there was a way to just "change the audio output device". - // (20 minutes later) i've done it nonetheless :] - val senderName = - aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" - Log.d( - TAG, - "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped" - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, - byteArrayOf(0x00) - ) - otherDeviceTookOver = true - disconnectAudio( - this@AirPodsService, device - ) - if (reasonReverseTapped) { - Log.d(TAG, "reverse tapped, disconnecting audio") - disconnectedBecauseReversed = true - disconnectAudio(this@AirPodsService, device) - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level - ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = true, - otherDeviceName = senderName - ) - } - if (!aacpManager.owns) { - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level - ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = reasonReverseTapped, - otherDeviceName = senderName - ) - } - MediaController.sendPause() - } - - override fun onShowNearbyUI(sender: String) { - val senderName = - aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = false, - otherDeviceName = senderName - ) - } - - override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) { - Log.d( - "AirPodsParser", - "Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}" - ) - // Store in SharedPreferences - sharedPreferences.edit { - putString("name", deviceInformation.name) - putString("airpods_model_number", deviceInformation.modelNumber) - putString("airpods_manufacturer", deviceInformation.manufacturer) - putString("airpods_serial_number", deviceInformation.serialNumber) - putString("airpods_left_serial_number", deviceInformation.leftSerialNumber) - putString("airpods_right_serial_number", deviceInformation.rightSerialNumber) - putString("airpods_version1", deviceInformation.version1) - putString("airpods_version2", deviceInformation.version2) - putString("airpods_version3", deviceInformation.version3) - putString("airpods_hardware_revision", deviceInformation.hardwareRevision) - putString("airpods_updater_identifier", deviceInformation.updaterIdentifier) - } - // Update config - config.airpodsName = deviceInformation.name - config.airpodsModelNumber = deviceInformation.modelNumber - config.airpodsManufacturer = deviceInformation.manufacturer - config.airpodsSerialNumber = deviceInformation.serialNumber - config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber - config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber - config.airpodsVersion1 = deviceInformation.version1 - config.airpodsVersion2 = deviceInformation.version2 - config.airpodsVersion3 = deviceInformation.version3 - config.airpodsHardwareRevision = deviceInformation.hardwareRevision - config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier - - val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - if (device != null) setMetadatas(device!!) - } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( - packageName - ) - ) - } - - @SuppressLint("NewApi") - override fun onHeadTrackingReceived(headTracking: ByteArray) { - if (isHeadTrackingActive) { - HeadTracking.processPacket(headTracking) - processHeadTrackingData(headTracking) - } - } - - override fun onProximityKeysReceived(proximityKeys: ByteArray) { - val keys = aacpManager.parseProximityKeysResponse(proximityKeys) - Log.d("AirPodsParser", "Proximity keys: $keys") - sharedPreferences.edit { - for (key in keys) { - Log.d("AirPodsParser", "Proximity key: ${key.key.name} = ${key.value}") - putString(key.key.name, Base64.encode(key.value)) - } - } - } - - override fun onStemPressReceived(stemPress: ByteArray) { - - val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) - - Log.d( - "AirPodsParser", - "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}" - ) - if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { - Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) - } else { - val action = getActionFor(bud, stemPressType) - Log.d("AirPodsParser", "$bud $stemPressType action: $action") - action?.let { executeStemAction(it) } - } - } - - override fun onAudioSourceReceived(audioSource: ByteArray) { - Log.d( - "AirPodsParser", - "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}" - ) - if (localMac!="" && (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac)) { - Log.d( - "AirPodsParser", - "Audio source is another device, better to give up aacp control" - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, - byteArrayOf(0x00) - ) - // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes -// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") -// MediaController.pausedForOtherDevice = false -// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change??? - } - } - - override fun onConnectedDevicesReceived(connectedDevices: List) { - for (device in connectedDevices) { - Log.d( - "AirPodsParser", - "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" - ) - } - val newDevices = connectedDevices.filter { newDevice -> - val notInOld = - aacpManager.oldConnectedDevices.none { oldDevice -> oldDevice.mac == newDevice.mac } - val notLocal = newDevice.mac != localMac - notInOld && notLocal - } - - for (device in newDevices) { - Log.d( - "AirPodsParser", - "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})" - ) - Log.d( - TAG, - "Sending new Tipi packet for device ${device.mac}, and sending media info to the device" - ) - aacpManager.sendMediaInformationNewDevice( - selfMacAddress = localMac, targetMacAddress = device.mac - ) - aacpManager.sendAddTiPiDevice( - selfMacAddress = localMac, targetMacAddress = device.mac - ) - } - } - - override fun onHeadphoneAccommodationReceived(eqData: FloatArray) { - sendBroadcast( - Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply { - setPackage(packageName) - }) - } - - override fun onCustomEqReceived(customEq: CustomEq) { - // TODO - } - - override fun onCapabilitiesReceived(capabilities: List) { - // TODO - } - - override fun onUnknownPacketReceived(packet: ByteArray) { - Log.d( - "AACPManager", - "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}" - ) - } - }) - } - - private fun getActionFor( - bud: AACPManager.Companion.StemPressBudType, type: StemPressType - ): StemAction? { - return when (type) { - StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction - StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction - StemPressType.TRIPLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftTriplePressAction else config.rightTriplePressAction - StemPressType.LONG_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftLongPressAction else config.rightLongPressAction - } - } - - private fun executeStemAction(action: StemAction) { - when (action) { - StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> { - Log.d( - "AirPodsParser", "Default single press action: Play/Pause, not taking action." - ) - } - - StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() - StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() - StemAction.NEXT_TRACK -> MediaController.sendNextTrack() - StemAction.DIGITAL_ASSISTANT -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) - } else { - Log.w( - "AirPodsParser", - "Digital Assistant action is not supported on this Android version." - ) - } - } - - StemAction.CYCLE_NOISE_CONTROL_MODES -> { - Log.d("AirPodsParser", "Cycling noise control modes") - sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE").apply { - setPackage(packageName) - }) - } - } - } - - private fun processEarDetectionChange(earDetection: ByteArray) { - var inEar: Boolean - val inEarData = listOf( - earDetectionNotification.status[0] == 0x00.toByte(), - earDetectionNotification.status[1] == 0x00.toByte() - ) - var justEnabledA2dp = false - earDetectionNotification.setStatus(earDetection) - if (config.earDetectionEnabled) { - val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size) - inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte() - - val newInEarData = listOf( - data[0] == 0x00.toByte(), data[1] == 0x00.toByte() - ) - - if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf( - false, false - ) && islandWindow?.isVisible != true - ) { - showIsland( - this@AirPodsService, - (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level ?: 0 - ) - ) - } - - if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { - islandWindow?.close() - } - - if (newInEarData.contains(true) && inEarData == listOf(false, false)) { - connectAudio(this@AirPodsService, device) - justEnabledA2dp = true - registerA2dpConnectionReceiver() - if (MediaController.getMusicActive()) { - MediaController.userPlayedTheMedia = true - } - } else if (newInEarData == listOf(false, false)) { - MediaController.sendPause(force = true) - if (config.disconnectWhenNotWearing) { - disconnectAudio(this@AirPodsService, device) - } - } - val wasNone = inEarData == listOf(false, false) - val nowSingle = newInEarData.count { it } == 1 - - if (wasNone && nowSingle) { - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - return - } - - if (inEarData.contains(false) && newInEarData == listOf(true, true)) { - Log.d("AirPodsParser", "User put in both AirPods from just one.") - MediaController.userPlayedTheMedia = false - } - - if (newInEarData.contains(false) && inEarData == listOf(true, true)) { - Log.d("AirPodsParser", "User took one of two out.") - MediaController.userPlayedTheMedia = false - } - - Log.d( - "AirPodsParser", - "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}" - ) - - if (newInEarData.sorted() != inEarData.sorted()) { - if (inEar) { - if (!justEnabledA2dp) { - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - } - } else { - MediaController.sendPause() - } - } - } - } - - private fun registerA2dpConnectionReceiver() { - val a2dpConnectionStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { - val state = intent.getIntExtra( - BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val previousState = intent.getIntExtra( - BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val device = - intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - - Log.d( - "MediaController", - "A2DP state changed: $previousState -> $state for device: ${device?.address}" - ) - - if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) { - - Log.d("MediaController", "A2DP connected, sending play command") - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - - context.unregisterReceiver(this) - } - } - } - } - - val a2dpIntentFilter = - IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) - } - } - - private fun initializeConfig() { - config = ServiceConfig( - deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods", - earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true), - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean( - "conversational_awareness_pause_music", false - ), - showPhoneBatteryInWidget = sharedPreferences.getBoolean( - "show_phone_battery_in_widget", true - ), - relativeConversationalAwarenessVolume = sharedPreferences.getBoolean( - "relative_conversational_awareness_volume", true - ), - headGestures = sharedPreferences.getBoolean("head_gestures", true), - disconnectWhenNotWearing = sharedPreferences.getBoolean( - "disconnect_when_not_wearing", false - ), - conversationalAwarenessVolume = sharedPreferences.getInt( - "conversational_awareness_volume", 43 - ), - qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", - - // AirPods state-based takeover - takeoverWhenDisconnected = sharedPreferences.getBoolean( - "takeover_when_disconnected", false - ), - takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false), - takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), - takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false), - - // Phone state-based takeover - takeoverWhenRingingCall = sharedPreferences.getBoolean( - "takeover_when_ringing_call", false - ), - takeoverWhenMediaStart = sharedPreferences.getBoolean( - "takeover_when_media_start", false - ), - - // Stem actions - leftSinglePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_single_press_action", "PLAY_PAUSE" - ) ?: "PLAY_PAUSE" - )!!, - rightSinglePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_single_press_action", "PLAY_PAUSE" - ) ?: "PLAY_PAUSE" - )!!, - - leftDoublePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_double_press_action", "PREVIOUS_TRACK" - ) ?: "NEXT_TRACK" - )!!, - rightDoublePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_double_press_action", "NEXT_TRACK" - ) ?: "NEXT_TRACK" - )!!, - - leftTriplePressAction = StemAction.fromString( - sharedPreferences.getString( - "left_triple_press_action", "PREVIOUS_TRACK" - ) ?: "PREVIOUS_TRACK" - )!!, - rightTriplePressAction = StemAction.fromString( - sharedPreferences.getString( - "right_triple_press_action", "PREVIOUS_TRACK" - ) ?: "PREVIOUS_TRACK" - )!!, - - leftLongPressAction = StemAction.fromString( - sharedPreferences.getString( - "left_long_press_action", "CYCLE_NOISE_CONTROL_MODES" - ) ?: "CYCLE_NOISE_CONTROL_MODES" - )!!, - rightLongPressAction = StemAction.fromString( - sharedPreferences.getString( - "right_long_press_action", "DIGITAL_ASSISTANT" - ) ?: "DIGITAL_ASSISTANT" - )!!, - - cameraAction = sharedPreferences.getString("camera_action", null) - ?.let { StemPressType.valueOf(it) }, - - // AirPods device information - airpodsName = sharedPreferences.getString("airpods_name", "") ?: "", - airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "", - airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "", - airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "", - airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") - ?: "", - airpodsRightSerialNumber = sharedPreferences.getString( - "airpods_right_serial_number", "" - ) ?: "", - airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "", - airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "", - airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "", - airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") - ?: "", - airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") - ?: "", - - selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: "" - ) - } - - override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { - if (preferences == null || key == null) return - - when (key) { - "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" - "mac_address" -> macAddress = preferences.getString(key, "") ?: "" - "automatic_ear_detection" -> config.earDetectionEnabled = - preferences.getBoolean(key, true) - - "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = - preferences.getBoolean(key, false) - - "show_phone_battery_in_widget" -> { - config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) - widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget - updateBattery() - } - - "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = - preferences.getBoolean(key, true) - - "head_gestures" -> config.headGestures = preferences.getBoolean(key, true) - "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = - preferences.getBoolean(key, false) - - "conversational_awareness_volume" -> config.conversationalAwarenessVolume = - preferences.getInt(key, 43) - - "qs_click_behavior" -> config.qsClickBehavior = - preferences.getString(key, "cycle") ?: "cycle" - - // AirPods state-based takeover - "takeover_when_disconnected" -> config.takeoverWhenDisconnected = - preferences.getBoolean(key, true) - - "takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true) - "takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false) - "takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true) - - // Phone state-based takeover - "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = - preferences.getBoolean(key, true) - - "takeover_when_media_start" -> config.takeoverWhenMediaStart = - preferences.getBoolean(key, true) - - "left_single_press_action" -> { - config.leftSinglePressAction = StemAction.fromString( - preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" - )!! - setupStemActions() - } - - "right_single_press_action" -> { - config.rightSinglePressAction = StemAction.fromString( - preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE" - )!! - setupStemActions() - } - - "left_double_press_action" -> { - config.leftDoublePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "right_double_press_action" -> { - config.rightDoublePressAction = StemAction.fromString( - preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK" - )!! - setupStemActions() - } - - "left_triple_press_action" -> { - config.leftTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "right_triple_press_action" -> { - config.rightTriplePressAction = StemAction.fromString( - preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK" - )!! - setupStemActions() - } - - "left_long_press_action" -> { - config.leftLongPressAction = StemAction.fromString( - preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") - ?: "CYCLE_NOISE_CONTROL_MODES" - )!! - setupStemActions() - } - - "right_long_press_action" -> { - config.rightLongPressAction = StemAction.fromString( - preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT" - )!! - setupStemActions() - } - - "camera_action" -> config.cameraAction = - preferences.getString(key, null)?.let { StemPressType.valueOf(it) } - - // AirPods device information - "airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: "" - "airpods_model_number" -> config.airpodsModelNumber = - preferences.getString(key, "") ?: "" - - "airpods_manufacturer" -> config.airpodsManufacturer = - preferences.getString(key, "") ?: "" - - "airpods_serial_number" -> config.airpodsSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_left_serial_number" -> config.airpodsLeftSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_right_serial_number" -> config.airpodsRightSerialNumber = - preferences.getString(key, "") ?: "" - - "airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: "" - "airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: "" - "airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: "" - "airpods_hardware_revision" -> config.airpodsHardwareRevision = - preferences.getString(key, "") ?: "" - - "airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = - preferences.getString(key, "") ?: "" - - "self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: "" - } - } - - private fun logPacket(packet: ByteArray, @Suppress("SameParameterValue") source: String) { - val packetHex = packet.joinToString(" ") { "%02X".format(it) } - val logEntry = "$source: $packetHex" - - synchronized(inMemoryLogs) { - inMemoryLogs.add(logEntry) - if (inMemoryLogs.size > maxLogEntries) { - inMemoryLogs.iterator().next().let { - inMemoryLogs.remove(it) - } - } - - _packetLogsFlow.value = inMemoryLogs.toSet() - } - - CoroutineScope(Dispatchers.IO).launch { - val logs = - sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() - ?: mutableSetOf() - logs.add(logEntry) - - if (logs.size > maxLogEntries) { - val toKeep = logs.toList().takeLast(maxLogEntries).toSet() - sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } - } else { - sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } - } - } - } - - private fun clearPacketLogs() { - synchronized(inMemoryLogs) { - inMemoryLogs.clear() - _packetLogsFlow.value = emptySet() - } - sharedPreferencesLogs.edit { remove(packetLogKey) } - } - - fun clearLogs() { - clearPacketLogs() - _packetLogsFlow.value = emptySet() - } - - override fun onBind(intent: Intent?): IBinder { - return LocalBinder() - } - - private var gestureDetector: GestureDetector? = null - private var isInCall = false - private var callNumber: String? = null - - private fun initGestureDetector() { - if (gestureDetector == null) { - gestureDetector = GestureDetector(this) - } - } - - - var popupShown = false - fun showPopup(service: Service, name: String) { - if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) { - return - } - if (!Settings.canDrawOverlays(service)) { - Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") - return - } - if (popupShown) { - return - } - val popupWindow = PopupWindow(service.applicationContext) - popupWindow.open(name, batteryNotification) - popupShown = true - } - - var islandOpen = false - var islandWindow: IslandWindow? = null - - @SuppressLint("MissingPermission") - fun showIsland( - service: Service, - batteryPercentage: Int, - type: IslandType = IslandType.CONNECTED, - reversed: Boolean = false, - otherDeviceName: String? = null - ) { - Log.d(TAG, "Showing island window") - if (!sharedPreferences.getBoolean("show_island_popup", true)) { - return - } - if (!Settings.canDrawOverlays(service)) { - Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") - return - } - CoroutineScope(Dispatchers.Main).launch { - islandWindow = IslandWindow(service.applicationContext) - islandWindow!!.show( - sharedPreferences.getString("name", "AirPods Pro").toString(), - batteryPercentage, - this@AirPodsService, - type, - reversed, - otherDeviceName - ) - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun startMainActivity() { - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } - - // var isConnectedLocally = false - var device: BluetoothDevice? = null - - private lateinit var earReceiver: BroadcastReceiver - var widgetMobileBatteryEnabled = false - - object BatteryChangedIntentReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent) { - if (intent.action == Intent.ACTION_BATTERY_CHANGED) { - ServiceManager.getService()?.updateBattery() - } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun startForegroundNotification() { - val disconnectedNotificationChannel = NotificationChannel( - "background_service_status", - "Background Service Status", - NotificationManager.IMPORTANCE_NONE - ) - - val connectedNotificationChannel = NotificationChannel( - "airpods_connection_status", - "AirPods Connection Status", - NotificationManager.IMPORTANCE_LOW, - ) - - val socketFailureChannel = NotificationChannel( - "socket_connection_failure", - "AirPods BluetoothConnectionManager.aacpSocket? Connection Issues", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Notifications about problems connecting to AirPods protocol" - enableLights(true) - lightColor = Color.RED - enableVibration(true) - } - - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(disconnectedNotificationChannel) - notificationManager.createNotificationChannel(connectedNotificationChannel) - notificationManager.createNotificationChannel(socketFailureChannel) - - val notificationSettingsIntent = - Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") - } - val pendingIntentNotifDisable = PendingIntent.getActivity( - this, - 0, - notificationSettingsIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, "background_service_status") - .setSmallIcon(R.drawable.airpods).setContentTitle("Background Service Running") - .setContentText("Useless notification, disable it by clicking on it.") - .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE) - .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() - - try { - startForeground(1, notification) - } catch (e: Exception) { - e.printStackTrace() - } - } - - @Suppress("KotlinUnreachableCode") - @OptIn(ExperimentalMaterial3Api::class) - private fun showSocketConnectionFailureNotification(errorMessage: String) { - return // something causes too many notifications. turning off for now - if (BuildConfig.FLAVOR != "xposed") { - Log.w( - TAG, - "Not showing BluetoothConnectionManager.aacpSocket? error notification to user, the service shouldn't be running if it isn't supported." - ) - return - } - val notificationManager = getSystemService(NotificationManager::class.java) - - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, "socket_connection_failure") - .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue") - .setContentText("Unable to connect to AirPods over L2CAP").setStyle( - NotificationCompat.BigTextStyle().bigText( - "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. Error: $errorMessage" - ) - ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR) - .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build() - - notificationManager.notify(3, notification) - } - - fun sendANCBroadcast() { - sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { - putExtra("data", ancNotification.status) - setPackage(packageName) - }) - } - - fun sendBatteryBroadcast() { - broadcastBatteryInformation() - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) - setPackage(packageName) - }) - } - - @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) - fun sendBatteryNotification() { - updateNotificationContent( - true, - getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name), - batteryNotification.getBattery() - ) - } - - fun setBatteryMetadata() { - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - device?.let { it -> - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level.toString() - .toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - fun updateBatteryWidget() { - val appWidgetManager = AppWidgetManager.getInstance(this) - val componentName = ComponentName(this, BatteryWidget::class.java) - val widgetIds = appWidgetManager.getAppWidgetIds(componentName) - - val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it -> - val openActivityIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - it.setOnClickPendingIntent(R.id.battery_widget, openActivityIntent) - - val leftBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT } - val rightBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT } - val caseBattery = - batteryNotification.getBattery().find { it.component == BatteryComponent.CASE } - - it.setTextViewText(R.id.left_battery_widget, leftBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.left_battery_progress, 100, leftBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.left_charging_icon, - if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE - ) - - it.setTextViewText(R.id.right_battery_widget, rightBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.right_battery_progress, 100, rightBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.right_charging_icon, - if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE - ) - - it.setTextViewText(R.id.case_battery_widget, caseBattery?.let { - "${it.level}%" - } ?: "") - it.setProgressBar( - R.id.case_battery_progress, 100, caseBattery?.level ?: 0, false - ) - it.setViewVisibility( - R.id.case_charging_icon, - if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE - ) - - it.setViewVisibility( - R.id.phone_battery_widget_container, - if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE - ) - if (widgetMobileBatteryEnabled) { - val batteryManager = getSystemService(BatteryManager::class.java) - val batteryLevel = - batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) - val charging = - batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) == BatteryManager.BATTERY_STATUS_CHARGING - it.setTextViewText( - R.id.phone_battery_widget, "$batteryLevel%" - ) - it.setViewVisibility( - R.id.phone_charging_icon, if (charging) View.VISIBLE else View.GONE - ) - it.setProgressBar( - R.id.phone_battery_progress, 100, batteryLevel, false - ) - } - } - appWidgetManager.updateAppWidget(widgetIds, remoteViews) - } - - @SuppressLint("MissingPermission") - @OptIn(ExperimentalMaterial3Api::class) - fun updateBattery() { - setBatteryMetadata() - updateBatteryWidget() - sendBatteryBroadcast() - sendBatteryNotification() - } - - fun updateNoiseControlWidget() { - val appWidgetManager = AppWidgetManager.getInstance(this) - val componentName = ComponentName(this, NoiseControlWidget::class.java) - val widgetIds = appWidgetManager.getAppWidgetIds(componentName) - val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { it -> - val ancStatus = ancNotification.status - val allowOffModeValue = - aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } - val allowOffMode = - allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true) - it.setInt( - R.id.widget_off_button, - "setBackgroundResource", - if (ancStatus == 1) R.drawable.widget_button_checked_shape_start else R.drawable.widget_button_shape_start - ) - it.setInt( - R.id.widget_transparency_button, - "setBackgroundResource", - if (ancStatus == 3) (if (allowOffMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (allowOffMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start) - ) - it.setInt( - R.id.widget_adaptive_button, - "setBackgroundResource", - if (ancStatus == 4) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_shape_middle - ) - it.setInt( - R.id.widget_anc_button, - "setBackgroundResource", - if (ancStatus == 2) R.drawable.widget_button_checked_shape_end else R.drawable.widget_button_shape_end - ) - it.setViewVisibility( - R.id.widget_off_button, if (allowOffMode) View.VISIBLE else View.GONE - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - it.setViewLayoutMargin( - R.id.widget_transparency_button, - RemoteViews.MARGIN_START, - if (allowOffMode) 2f else 12f, - TypedValue.COMPLEX_UNIT_DIP - ) - } else { - it.setViewPadding( - R.id.widget_transparency_button, - if (allowOffMode) 2.dpToPx() else 12.dpToPx(), - 12.dpToPx(), - 2.dpToPx(), - 12.dpToPx() - ) - } - } - - appWidgetManager.updateAppWidget(widgetIds, remoteViews) - } - - @OptIn(ExperimentalMaterial3Api::class) - fun updateNotificationContent( - connected: Boolean, airpodsName: String? = null, batteryList: List? = null - ) { - val notificationManager = getSystemService(NotificationManager::class.java) - - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - if (BluetoothConnectionManager.aacpSocket == null) { - return - } - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { - val updatedNotificationBuilder = - NotificationCompat.Builder(this, "airpods_connection_status") - .setSmallIcon(R.drawable.airpods) - .setContentTitle(airpodsName ?: config.deviceName).setContentText( - """${ - batteryList?.find { it.component == BatteryComponent.LEFT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "R: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.CASE }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "Case: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - }""").setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS) - .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true) - - if (disconnectedBecauseReversed) { - updatedNotificationBuilder.addAction( - R.drawable.ic_bluetooth, "Reconnect", PendingIntent.getService( - this, 0, Intent(this, AirPodsService::class.java).apply { - action = "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE" - }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - ) - } - - val updatedNotification = updatedNotificationBuilder.build() - - notificationManager.notify(2, updatedNotification) - notificationManager.cancel(1) - } else if (!connected) { - notificationManager.cancel(2) - } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { - showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs") - } - } - - fun handleIncomingCall() { - if (isInCall) return - if (config.headGestures) { - initGestureDetector() - startHeadTracking() - gestureDetector?.startDetection { accepted -> - if (accepted) { - answerCall() - handleIncomingCallOnceConnected = false - } else { - rejectCall() - handleIncomingCallOnceConnected = false - } - } - - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun testHeadGestures(): Boolean { - return suspendCancellableCoroutine { continuation -> - gestureDetector?.startDetection(doNotStop = true) { accepted -> - if (continuation.isActive) { - continuation.resume(accepted) { _, _, _ -> - gestureDetector?.stopDetection() - } - } - } - } - } - - private fun answerCall() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager - if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association) - } - } else { - val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - val telephonyClass = Class.forName(telephonyService.javaClass.name) - val method = telephonyClass.getDeclaredMethod("getITelephony") - method.isAccessible = true - val telephonyInterface = method.invoke(telephonyService) - val answerCallMethod = - telephonyInterface.javaClass.getDeclaredMethod("answerRingingCall") - answerCallMethod.invoke(telephonyInterface) - } - - sendToast("Call answered via head gesture") - } catch (e: Exception) { - e.printStackTrace() - sendToast("Failed to answer call: ${e.message}") - } finally { - islandWindow?.close() - } - } - - private fun rejectCall() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager - if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) - } - } else { - val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - val telephonyClass = Class.forName(telephonyService.javaClass.name) - val method = telephonyClass.getDeclaredMethod("getITelephony") - method.isAccessible = true - val telephonyInterface = method.invoke(telephonyService) - val endCallMethod = telephonyInterface.javaClass.getDeclaredMethod("endCall") - endCallMethod.invoke(telephonyInterface) - } - - sendToast("Call rejected via head gesture") - } catch (e: Exception) { - e.printStackTrace() - sendToast("Failed to reject call: ${e.message}") - } finally { - islandWindow?.close() - } - } - - fun sendToast(message: String) { - Handler(Looper.getMainLooper()).post { - Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() - } - } - - @RequiresApi(Build.VERSION_CODES.R) - fun processHeadTrackingData(data: ByteArray) { - val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() - val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() - try { - gestureDetector?.processHeadOrientation(horizontal, vertical) - } catch (e: Exception) { - Log.w(TAG, "gesture detector on ${data.toHexString()}: ${e.message}") - } - } - - private lateinit var connectionReceiver: BroadcastReceiver - - private fun resToUri(resId: Int): Uri? { - return try { - Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority("me.kavishdevar.librepods") - .appendPath(applicationContext.resources.getResourceTypeName(resId)) - .appendPath(applicationContext.resources.getResourceEntryName(resId)).build() - } catch (_: Resources.NotFoundException) { - null - } - } - - @Suppress("PrivatePropertyName") - private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV" - - @Suppress("PrivatePropertyName") - private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1 - - @Suppress("PrivatePropertyName") - private val APPLE = 0x004C - - @Suppress("PrivatePropertyName") - private val ACTION_BATTERY_LEVEL_CHANGED = - "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" - - @Suppress("PrivatePropertyName") - private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" - - @Suppress("PrivatePropertyName") - private val PACKAGE_ASI = "com.google.android.settings.intelligence" - - @Suppress("PrivatePropertyName") - private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" - - @SuppressLint("MissingPermission") - fun broadcastBatteryInformation() { - if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return - - val batteryList = batteryNotification.getBattery() - val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } - val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } - - // Calculate unified battery level (minimum of left and right) - val batteryUnified = minOf( - leftBattery?.level ?: 100, rightBattery?.level ?: 100 - ) - - // Check charging status - val isLeftCharging = leftBattery?.status == BatteryStatus.CHARGING - val isRightCharging = rightBattery?.status == BatteryStatus.CHARGING - isLeftCharging && isRightCharging - - // Create arguments for vendor-specific event - val arguments = arrayOf( - 1, // Number of key/value pairs - VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level - batteryUnified // Battery Level - ) - - // Broadcast vendor-specific event - val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply { - putExtra( - BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, - VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV - ) - putExtra( - BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, - BluetoothHeadset.AT_CMD_TYPE_SET - ) - putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments) - putExtra(BluetoothDevice.EXTRA_DEVICE, device) - putExtra(BluetoothDevice.EXTRA_NAME, device?.name) - addCategory("${BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY}.$APPLE") - } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sendBroadcastAsUser( - intent, - UserHandle.getUserHandleForUid(-1), - Manifest.permission.BLUETOOTH_CONNECT - ) - } else { - sendBroadcastAsUser(intent, UserHandle.getUserHandleForUid(-1)) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to send vendor-specific event: ${e.message}") - } - - // Broadcast battery level changes - val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply { - putExtra(BluetoothDevice.EXTRA_DEVICE, device) - putExtra(EXTRA_BATTERY_LEVEL, batteryUnified) - } - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sendBroadcast(batteryIntent, Manifest.permission.BLUETOOTH_CONNECT) - } else { - sendBroadcastAsUser(batteryIntent, UserHandle.getUserHandleForUid(-1)) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to send battery level broadcast: ${e.message}") - } - - // Update Android Settings Intelligence's battery widget - val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).apply { - setPackage(PACKAGE_ASI) - putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent) - } - - try { - sendBroadcastAsUser(statusIntent, UserHandle.getUserHandleForUid(-1)) - } catch (e: Exception) { - Log.e(TAG, "Failed to send ASI battery level broadcast: ${e.message}") - } - - Log.d(TAG, "Broadcast battery level $batteryUnified% to system") - } - - private fun setMetadatas(d: BluetoothDevice) { - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning") - return - } - Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding") - d.let { device -> - val instance = airpodsInstance - if (instance != null) { - val metadataSet = SystemApisUtils.setMetadata( - device, - device.METADATA_MAIN_ICON, - resToUri(instance.model.budCaseRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, device.METADATA_MODEL_NAME, instance.model.name.toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_DEVICE_TYPE, - device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_ICON, - resToUri(instance.model.caseRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_ICON, - resToUri(instance.model.rightBudsRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_ICON, - resToUri(instance.model.leftBudsRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_MANUFACTURER_NAME, - instance.model.manufacturer.toByteArray() - ) && SystemApisUtils.setMetadata( - device, device.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) - Log.d(TAG, "Metadata set: $metadataSet") - } else { - Log.w( - TAG, - "AirPods demoInstance is not of type AirPodsInstance, skipping metadata setting" - ) - } - } - } - - @Suppress("ClassName") - private object bluetoothReceiver : BroadcastReceiver() { - @SuppressLint("MissingPermission") - override fun onReceive(context: Context?, intent: Intent) { - val bluetoothDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra( - "android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java - ) - } else { - intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice? - } - val action = intent.action - val context = context?.applicationContext - val name = context?.getSharedPreferences("settings", MODE_PRIVATE) - ?.getString("name", bluetoothDevice?.name) - if (bluetoothDevice != null && !action.isNullOrEmpty()) { - Log.d(TAG, "Received bluetooth connection broadcast: action=$action") - val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - - if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { - if (bluetoothDevice.uuids?.contains(uuid) == true) { - val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } else { - bluetoothDevice.fetchUuidsWithSdp() - } - } else if ("android.bluetooth.device.action.UUID" == action) { - val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) - ?.getString("mac_address", "") ?: "" - val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac - val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true - if (matchedByUuid || matchedByMac) { - val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } - } - } - } - } - - val externalBroadcastFilter = IntentFilter().apply { - addAction("me.kavishdevar.librepods.SET_ANC_MODE") - addAction("me.kavishdevar.librepods.CONVO_DETECT") - } - var externalBroadcastReceiver: BroadcastReceiver? = null - - @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "Service started with intent action: ${intent?.action}") - - if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { - Log.d(TAG, "reconnect after reversed received, taking over") - disconnectedBecauseReversed = false - otherDeviceTookOver = false - takeOver("music", manualTakeOverAfterReversed = true) - } - - return START_STICKY - } - - @RequiresApi(Build.VERSION_CODES.R) - @SuppressLint("MissingPermission", "HardwareIds") - fun takeOver( - takingOverFor: String, - manualTakeOverAfterReversed: Boolean = false, - startHeadTrackingAgain: Boolean = false - ) { - if (takingOverFor == "reverse") { - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1 - ) - aacpManager.sendMediaInformataion( - localMac - ) - aacpManager.sendHijackReversed( - localMac - ) - connectAudio( - this@AirPodsService, device - ) - otherDeviceTookOver = false - } - val ownsConnection = aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt() - Log.d( - TAG, "owns connection: $ownsConnection" - ) - if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { - if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { - Log.d(TAG, "not taking over, vendorid is probably not set to apple") - return - } - if (aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value[0]?.toInt() != 1 || (aacpManager.audioSource?.mac != localMac && aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE)) { - if (disconnectedBecauseReversed) { - if (manualTakeOverAfterReversed) { - Log.d(TAG, "forcefully taking over despite reverse as user requested") - disconnectedBecauseReversed = false - } else { - Log.d( - TAG, - "connected locally, but can not hijack as other device had reversed" - ) - return - } - } - - Log.d(TAG, "already connected locally, hijacking connection by asking AirPods") - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1 - ) - aacpManager.sendMediaInformataion( - localMac - ) - aacpManager.sendSmartRoutingShowUI( - localMac - ) - aacpManager.sendHijackRequest( - localMac - ) - otherDeviceTookOver = false - connectAudio(this, device) - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.CONNECTED - ) - - CoroutineScope(Dispatchers.IO).launch { - delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting - if (takingOverFor == "music") { - Log.d(TAG, "Resuming music after taking control") - MediaController.sendPlay(replayWhenPaused = true) - } else if (startHeadTrackingAgain) { - Log.d(TAG, "Starting head tracking again after taking control") - Handler(Looper.getMainLooper()).postDelayed({ - startHeadTracking() - }, 500) - } - delay(1000) // should ideally have a callback when it's taken over because for some reason android doesn't dispatch when it's paused - if (takingOverFor == "music") { - Log.d(TAG, "resuming again just in case") - MediaController.sendPlay(force = true) - } - } - } else { - Log.d( - TAG, "Already connected locally and already own connection, skipping takeover" - ) - } - return - } - -// if (CrossDevice.isAvailable) { -// Log.d(TAG, "CrossDevice is available, continuing") -// } -// else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { -// Log.d(TAG, "At least one AirPod is in ear, continuing") -// } -// else { -// Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") -// return -// } - - if (bleManager.getMostRecentStatus()?.isLeftInEar == false && bleManager.getMostRecentStatus()?.isRightInEar == false) { - Log.d(TAG, "Both AirPods are out of ear, not taking over audio") - return - } - - val shouldTakeOverPState = when (takingOverFor) { - "music" -> config.takeoverWhenMediaStart - "call" -> config.takeoverWhenRingingCall - else -> false - } - - if (!shouldTakeOverPState) { - Log.d(TAG, "Not taking over audio, phone state takeover disabled") - return - } - - val shouldTakeOver = when (bleManager.getMostRecentStatus()?.connectionState) { - "Disconnected" -> config.takeoverWhenDisconnected - "Idle" -> config.takeoverWhenIdle - "Music" -> config.takeoverWhenMusic - "Call" -> config.takeoverWhenCall - "Ringing" -> config.takeoverWhenCall - "Hanging Up" -> config.takeoverWhenCall - else -> false - } - - if (!shouldTakeOver) { - Log.d(TAG, "Not taking over audio, airpods state takeover disabled") - return - } - - if (takingOverFor == "music") { - Log.d(TAG, "Pausing music so that it doesn't play through speakers") - MediaController.pausedWhileTakingOver = true - MediaController.sendPause(true) - } else { - handleIncomingCallOnceConnected = true - } - - Log.d(TAG, "Taking over audio") -// CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) - Log.d(TAG, macAddress) - -// sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - device = bluetoothAdapter.bondedDevices.find { - it.address == macAddress - } - - if (device != null) { - if (config.bleOnlyMode) { - // In BLE-only mode, just show connecting status without actual L2CAP connection - Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection") - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() - ) - // Set a temporary connecting state -// isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP - } else { - connectToSocket(bluetoothAdapter, device!!) - connectAudio(this, device) -// isConnectedLocally = true - } - } - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.TAKING_OVER - ) - -// CrossDevice.isAvailable = false - } - - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") - fun connectToSocket( - adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false - ) { - if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return - Log.d(TAG, " Connecting to socket") - val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") -// if (!isConnectedLocally) { - val socket = try { - createBluetoothSocket(adapter, device, uuid, 4097) - } catch (e: Exception) { - Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") - showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") - return - } - - try { - runBlocking { - withTimeout(5000.milliseconds) { - try { - socket.connect() - this@AirPodsService.device = device - val xposedRemotePref = XposedRemotePrefProvider.create() - val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - attSocket?.connect() - - if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) - } - - BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket - - // Create AirPodsInstance from stored config if available - if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { - val model = - AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - setMetadatas(device) - } - } - - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() - ) - Log.d(TAG, " Socket connected") - sharedPreferences.edit { putBoolean("connection_successful", true) } - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong( - "first_connection_successful_time", - System.currentTimeMillis() - ) - } - } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) - } catch (e: Exception) { -// sharedPreferences.edit { putBoolean("connection_successful", false) } - Log.d( - TAG, " Socket not connected, ${e.message}" - ) - if (manual) { - sendToast( - "Couldn't connect to socket: ${e.localizedMessage}" - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") - } - return@withTimeout -// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history - } - } - } - if (!socket.isConnected) { - Log.d(TAG, " socket not connected") - if (manual) { - sendToast( - "Couldn't connect to socket: timeout." - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") - } - return - } - this@AirPodsService.device = device - BluetoothConnectionManager.aacpSocket?.let { - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - Log.d(TAG, "Requesting proximity keys") - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - CoroutineScope(Dispatchers.IO).launch { - delay(200) - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - delay(200) - aacpManager.sendSetFeatureFlagsPacket() - delay(200) - aacpManager.sendNotificationRequest() - delay(200) - aacpManager.sendSomePacketIDontKnowWhatItIs() - delay(200) - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() - Handler(Looper.getMainLooper()).postDelayed({ - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) - if (!handleIncomingCallOnceConnected) stopHeadTracking() - }, 5000) - - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) - .apply { - setPackage(packageName) - }) - - setupStemActions() - - while (socket.isConnected) { - try { - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) - - aacpManager.receivePacket(data) - - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } - - } else if (bytesRead == -1) { - Log.d("AirPodsService", "socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - - } - Log.d("AirPods Service", "socket closed") -// isConnectedLocally = false - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - } - } - } catch (e: Exception) { - e.printStackTrace() - Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") - showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") -// isConnectedLocally = false - this@AirPodsService.device = device - updateNotificationContent(false) - } -// } else { -// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})") -// } - } - - fun disconnectForCD() { - BluetoothConnectionManager.aacpSocket?.close() - MediaController.pausedWhileTakingOver = false - Log.d(TAG, "Disconnected from AirPods, showing island.") - showIsland( - this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), - IslandType.MOVED_TO_REMOTE - ) - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) -// isConnectedLocally = false -// CrossDevice.isAvailable = true - } - - fun disconnectAirPods() { - if (BluetoothConnectionManager.aacpSocket == null) return - try { - BluetoothConnectionManager.aacpSocket?.close() - } catch(e: Exception) { - Log.e(TAG, "error closing aacp socket ${e.message}") - } -// isConnectedLocally = false - aacpManager.disconnected() - - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null - - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - try { - device?.disconnect() - } catch (e: Exception) { - Log.w(TAG, "device.disconnect() failed, $e") - } - } - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } - Log.d(TAG, "Disconnected AirPods upon user request") - } - - val earDetectionNotification = AirPodsNotifications.EarDetection() - val ancNotification = AirPodsNotifications.ANC() - val batteryNotification = AirPodsNotifications.BatteryNotification() - val conversationAwarenessNotification = - AirPodsNotifications.ConversationalAwarenessNotification() - - @Suppress("unused") - fun setEarDetection(enabled: Boolean) { - if (config.earDetectionEnabled != enabled) { - config.earDetectionEnabled = enabled - sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) } - } - } - - fun getBattery(): List { -// if (!isConnectedLocally && CrossDevice.isAvailable) { -// batteryNotification.setBattery(CrossDevice.batteryBytes) -// } - return batteryNotification.getBattery() - } - - fun getANC(): Int { -// if (!isConnectedLocally && CrossDevice.isAvailable) { -// ancNotification.setStatus(CrossDevice.ancBytes) -// } - return ancNotification.status - } - - fun disconnectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - try { - if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d(TAG, "Already disconnected from A2DP") - return - } - val method = proxy.javaClass.getMethod( - "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - } else { - Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") - } - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - try { - val method = - proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } else { - Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") - } - } - - fun connectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") - policyMethod.invoke(proxy, device, 100) - - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - if (MediaController.pausedWhileTakingOver) { - MediaController.sendPlay() - } - } - } - else { - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d( - TAG, - "calling HEADSET.setConnectionPolicy for ${device?.address} to 100" - ) - policyMethod.invoke(proxy, device, 100) - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke(proxy, device) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } else { - Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission") - } - } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } - - fun setName(name: String) { - aacpManager.sendRename(name) - - if (config.deviceName != name) { - config.deviceName = name - device?.alias = name - sharedPreferences.edit { putString("name", name) } - } - - updateNotificationContent(true, name, batteryNotification.getBattery()) - Log.d(TAG, "setName: $name") - } - - @SuppressLint("MissingPermission") - override fun onDestroy() { - clearPacketLogs() - Log.d(TAG, "Service stopped is being destroyed for some reason!") - - sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) - - try { - unregisterReceiver(bluetoothReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(externalBroadcastReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(connectionReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - unregisterReceiver(earReceiver) - } catch (e: Exception) { - e.printStackTrace() - } - try { - bleManager.stopScanning() - } catch (e: Exception) { - e.printStackTrace() - } - if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - telephonyManager.unregisterTelephonyCallback(phoneStateListener) - } -// isConnectedLocally = false -// CrossDevice.isAvailable = true - super.onDestroy() - } - - var isHeadTrackingActive = false - - fun startHeadTracking() { - isHeadTrackingActive = true - val useAlternatePackets = - sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus( - AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION - )?.value?.get(0)?.toInt() != 1 - ) { - takeOver("call", startHeadTrackingAgain = true) - Log.d(TAG, "Taking over for head tracking") - } else { - Log.w(TAG, "Will not be taking over for head tracking, might not work.") - } - if (useAlternatePackets) { - aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket()) - } else { - aacpManager.sendStartHeadTracking() - } - HeadTracking.reset() - } - - fun stopHeadTracking() { - val useAlternatePackets = - sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true) - if (useAlternatePackets) { - aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket()) - } else { - aacpManager.sendStopHeadTracking() - } - isHeadTrackingActive = false - gestureDetector?.stopDetection() - } - - @SuppressLint("MissingPermission") - fun reconnectFromSavedMac() { - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - device = bluetoothAdapter.bondedDevices.find { - it.address == macAddress - } - if (device != null) { - CoroutineScope(Dispatchers.IO).launch { - Log.d(TAG, "connecting to $macAddress") - connectToSocket(bluetoothAdapter, device!!, manual = true) - connectAudio(this@AirPodsService, device!!) - } - } - } -} - -private fun Int.dpToPx(): Int { - val density = Resources.getSystem().displayMetrics.density - return (this * density).toInt() -} - -fun getNextMode(currentMode: Int, configByte: Int, offmodeEnabled: Boolean): Int { - val enabledModes = buildList { - if ((configByte and 0x01) != 0 && offmodeEnabled) add(1) - if ((configByte and 0x04) != 0) add(3) - if ((configByte and 0x08) != 0) add(4) - if ((configByte and 0x02) != 0) add(2) - } - Log.d(TAG, "currentMode: $currentMode, config: ${configByte.toString(2)}") - - if (enabledModes.isEmpty()) return currentMode - - val currentIndex = enabledModes.indexOf(currentMode) - val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledModes.size - - return enabledModes[nextIndex] -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt deleted file mode 100644 index 7eeea7c50..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.services - - -import android.accessibilityservice.AccessibilityService -import android.util.Log -import android.view.accessibility.AccessibilityEvent -import kotlin.io.encoding.ExperimentalEncodingApi - -private const val TAG="AppListenerService" - -val cameraPackages = mutableSetOf( - "com.google.android.GoogleCamera", - "com.sec.android.app.camera", - "com.android.camera", - "com.oppo.camera", - "com.motorola.camera2", - "org.codeaurora.snapcam" -) - -var cameraOpen = false -private var currentCustomPackage: String? = null - -class AppListenerService: AccessibilityService() { - private lateinit var prefs: android.content.SharedPreferences - private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - if (key == "custom_camera_package") { - val newPackage = sharedPreferences.getString(key, null) - currentCustomPackage?.let { cameraPackages.remove(it) } - if (!newPackage.isNullOrBlank()) { - cameraPackages.add(newPackage) - } - currentCustomPackage = newPackage - } - } - - override fun onCreate() { - super.onCreate() - prefs = getSharedPreferences("settings", MODE_PRIVATE) - val customPackage = prefs.getString("custom_camera_package", null) - if (!customPackage.isNullOrBlank()) { - cameraPackages.add(customPackage) - currentCustomPackage = customPackage - } - prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - } - - override fun onDestroy() { - super.onDestroy() - prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) - } - - override fun onAccessibilityEvent(ev: AccessibilityEvent?) { - try { - if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { - val pkg = ev.packageName?.toString() ?: return - if (pkg == "com.android.systemui") return // after camera opens, systemui is opened, probably for the privacy indicators - Log.d(TAG, "Package: $pkg, cameraOpen: $cameraOpen") - if (pkg in cameraPackages) { - Log.d(TAG, "Camera app opened: $pkg") - if (!cameraOpen) cameraOpen = true - ServiceManager.getService()?.cameraOpened() - } else { - if (cameraOpen) { - cameraOpen = false - ServiceManager.getService()?.cameraClosed() - } else { - Log.d(TAG, "ignoring") - } - } - // Log.d(TAG, "Opened: $pkg") - } - } catch(e: Exception) { - Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}") - } - } - - override fun onInterrupt() {} -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt deleted file mode 100644 index e10ea20d5..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.utils - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerId -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed -import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.util.fastFirstOrNull - -suspend fun PointerInputScope.inspectDragGestures( - onDragStart: (down: PointerInputChange) -> Unit = {}, - onDragEnd: (change: PointerInputChange) -> Unit = {}, - onDragCancel: () -> Unit = {}, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit -) { - awaitEachGesture { - val initialDown = awaitFirstDown(false, PointerEventPass.Initial) - - val down = awaitFirstDown(false) - - onDragStart(down) - onDrag(initialDown, Offset.Zero) - val upEvent = - drag( - pointerId = initialDown.id, - onDrag = { onDrag(it, it.positionChange()) } - ) - if (upEvent == null) { - onDragCancel() - } else { - onDragEnd(upEvent) - } - } -} - -private suspend inline fun AwaitPointerEventScope.drag( - pointerId: PointerId, - onDrag: (PointerInputChange) -> Unit -): PointerInputChange? { - val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true - if (isPointerUp) { - return null - } - var pointer = pointerId - while (true) { - val change = awaitDragOrUp(pointer) ?: return null - if (change.isConsumed) { - return null - } - if (change.changedToUpIgnoreConsumed()) { - return change - } - onDrag(change) - pointer = change.id - } -} - -private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( - pointerId: PointerId -): PointerInputChange? { - var pointer = pointerId - while (true) { - val event = awaitPointerEvent() - val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null - if (dragEvent.changedToUpIgnoreConsumed()) { - val otherDown = event.changes.fastFirstOrNull { it.pressed } - if (otherDown == null) { - return dragEvent - } else { - pointer = otherDown.id - } - } else { - val hasDragged = dragEvent.previousPosition != dragEvent.position - if (hasDragged) { - return dragEvent - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt deleted file mode 100644 index 9892096bc..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ /dev/null @@ -1,416 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.utils - -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.services.ServiceManager -import java.util.Collections -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow - -class GestureDetector( - private val airPodsService: AirPodsService -) { - companion object { - private const val TAG = "GestureDetector" - - private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600 - private const val DIRECTION_CHANGE_SENSITIVITY = 150 - - private const val FAST_MOVEMENT_THRESHOLD = 300.0 - private const val MIN_REQUIRED_EXTREMES = 3 - private const val MAX_REQUIRED_EXTREMES = 4 - - private const val MAX_VALID_ORIENTATION_VALUE = 6000 - } - - val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!) - - private val horizontalBuffer = Collections.synchronizedList(ArrayList()) - private val verticalBuffer = Collections.synchronizedList(ArrayList()) - - private val horizontalAvgBuffer = Collections.synchronizedList(ArrayList()) - private val verticalAvgBuffer = Collections.synchronizedList(ArrayList()) - - private var prevHorizontal: Double = 0.0 - private var prevVertical: Double = 0.0 - - private val horizontalPeaks = CopyOnWriteArrayList>() - private val horizontalTroughs = CopyOnWriteArrayList>() - private val verticalPeaks = CopyOnWriteArrayList>() - private val verticalTroughs = CopyOnWriteArrayList>() - - private var lastPeakTime: Long = 0 - private val peakIntervals = Collections.synchronizedList(ArrayList()) - - private val movementSpeedIntervals = Collections.synchronizedList(ArrayList()) - - private val peakThreshold = 400 - private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY - private val rhythmConsistencyThreshold = 0.5 - - private var horizontalIncreasing: Boolean? = null - private var verticalIncreasing: Boolean? = null - - private val minConfidenceThreshold = 0.7 - - private var isRunning = false - private var detectionJob: Job? = null - private var gestureDetectedCallback: ((Boolean) -> Unit)? = null - - private var significantMotion = false - private var lastSignificantMotionTime = 0L - - init { - while (horizontalAvgBuffer.size < 3) horizontalAvgBuffer.add(0.0) - while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0) - } - -fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) { - if (isRunning) return - - Log.d(TAG, "Starting gesture detection...") - isRunning = true - gestureDetectedCallback = onGestureDetected - - Log.d(TAG, "started: ${airPodsService.startHeadTracking()}") - - clearData() - - prevHorizontal = 0.0 - prevVertical = 0.0 - - detectionJob = CoroutineScope(Dispatchers.Default).launch { - while (isRunning) { - delay(50) - - val gesture = detectGestures() - if (gesture != null) { - withContext(Dispatchers.Main) { - audio.playConfirmation(gesture) - - gestureDetectedCallback?.invoke(gesture) - stopDetection(doNotStop) - } - break - } - } - } - } - fun stopDetection(doNotStop: Boolean = false) { - if (!isRunning) return - - Log.d(TAG, "Stopping gesture detection") - isRunning = false - - if (!doNotStop) airPodsService.stopHeadTracking() - - detectionJob?.cancel() - detectionJob = null - gestureDetectedCallback = null - } - - @RequiresApi(Build.VERSION_CODES.R) - fun processHeadOrientation(horizontal: Int, vertical: Int) { - if (!isRunning) return - - if (abs(horizontal) > MAX_VALID_ORIENTATION_VALUE || abs(vertical) > MAX_VALID_ORIENTATION_VALUE) { - Log.d(TAG, "Ignoring likely calibration data: h=$horizontal, v=$vertical") - return - } - - val horizontalDelta = horizontal - prevHorizontal - val verticalDelta = vertical - prevVertical - - val significantHorizontal = abs(horizontalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD - val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD - - if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = false, value = horizontalDelta) - } - significantMotion = true - lastSignificantMotionTime = System.currentTimeMillis() - Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta") - } - else if (significantVertical) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = true, value = verticalDelta) - } - significantMotion = true - lastSignificantMotionTime = System.currentTimeMillis() - Log.d(TAG, "Significant VERTICAL movement: $verticalDelta") - } - else if (significantMotion && - (System.currentTimeMillis() - lastSignificantMotionTime) > 300) { - significantMotion = false - } - - prevHorizontal = horizontal.toDouble() - prevVertical = vertical.toDouble() - - val smoothHorizontal = applySmoothing(horizontal.toDouble(), horizontalAvgBuffer) - val smoothVertical = applySmoothing(vertical.toDouble(), verticalAvgBuffer) - - synchronized(horizontalBuffer) { - horizontalBuffer.add(smoothHorizontal) - if (horizontalBuffer.size > 100) horizontalBuffer.removeAt(0) - } - - synchronized(verticalBuffer) { - verticalBuffer.add(smoothVertical) - if (verticalBuffer.size > 100) verticalBuffer.removeAt(0) - } - - detectPeaksAndTroughs() - } - - private fun applySmoothing(newValue: Double, buffer: MutableList): Double { - synchronized(buffer) { - buffer.add(newValue) - if (buffer.size > 3) buffer.removeAt(0) - return buffer.average() - } - } - - - private fun detectPeaksAndTroughs() { - if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return - - val hValues = horizontalBuffer.takeLast(4) - val vValues = verticalBuffer.takeLast(4) - val hVariance = calculateVariance(hValues) - val vVariance = calculateVariance(vValues) - - processDirectionChanges( - horizontalBuffer, - horizontalIncreasing, - hVariance, - horizontalPeaks, - horizontalTroughs - )?.let { horizontalIncreasing = it } - - processDirectionChanges( - verticalBuffer, - verticalIncreasing, - vVariance, - verticalPeaks, - verticalTroughs - )?.let { verticalIncreasing = it } - } - - private fun processDirectionChanges( - buffer: List, - isIncreasing: Boolean?, - variance: Double, - peaks: MutableList>, - troughs: MutableList> - ): Boolean? { - if (buffer.size < 2) return isIncreasing - - val current = buffer.last() - val prev = buffer[buffer.size - 2] - var increasing = isIncreasing ?: (current > prev) - - val dynamicThreshold = max(50.0, min(directionChangeThreshold.toDouble(), variance / 3)) - - val now = System.currentTimeMillis() - - if (increasing && current < prev - dynamicThreshold) { - if (abs(prev) > peakThreshold) { - peaks.add(Triple(buffer.size - 1, prev, now)) - if (lastPeakTime > 0) { - val interval = (now - lastPeakTime) / 1000.0 - val timeDiff = now - lastPeakTime - - synchronized(peakIntervals) { - peakIntervals.add(interval) - if (peakIntervals.size > 5) peakIntervals.removeAt(0) - } - - synchronized(movementSpeedIntervals) { - movementSpeedIntervals.add(timeDiff) - if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0) - } - } - lastPeakTime = now - } - increasing = false - } else if (!increasing && current > prev + dynamicThreshold) { - if (abs(prev) > peakThreshold) { - troughs.add(Triple(buffer.size - 1, prev, now)) - - if (lastPeakTime > 0) { - val interval = (now - lastPeakTime) / 1000.0 - val timeDiff = now - lastPeakTime - - synchronized(peakIntervals) { - peakIntervals.add(interval) - if (peakIntervals.size > 5) peakIntervals.removeAt(0) - } - - synchronized(movementSpeedIntervals) { - movementSpeedIntervals.add(timeDiff) - if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0) - } - } - lastPeakTime = now - } - increasing = true - } - - return increasing - } - - private fun calculateVariance(values: List): Double { - if (values.size <= 1) return 0.0 - - val mean = values.average() - val squaredDiffs = values.map { (it - mean) * (it - mean) } - return squaredDiffs.average() - } - - - private fun calculateRhythmConsistency(): Double { - if (peakIntervals.size < 2) return 0.0 - - val meanInterval = peakIntervals.average() - if (meanInterval == 0.0) return 0.0 - - val variances = peakIntervals.map { (it / meanInterval - 1.0).pow(2) } - val consistency = 1.0 - min(1.0, variances.average() / rhythmConsistencyThreshold) - return max(0.0, consistency) - } - - - private fun calculateConfidenceScore(extremes: List>, isVertical: Boolean): Double { - if (extremes.size < getRequiredExtremes()) return 0.0 - - val sortedExtremes = extremes.sortedBy { it.first } - - val recent = sortedExtremes.takeLast(getRequiredExtremes()) - - val avgAmplitude = recent.map { abs(it.second) }.average() - val amplitudeFactor = min(1.0, avgAmplitude / 600) - - val rhythmFactor = calculateRhythmConsistency() - - val signs = recent.map { if (it.second > 0) 1 else -1 } - val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] } - val alternationFactor = if (alternating) 1.0 else 0.5 - - val isolationFactor = if (isVertical) { - val vertAmplitude = recent.map { abs(it.second) }.average() - val horizVals = horizontalBuffer.takeLast(recent.size * 2) - val horizAmplitude = horizVals.map { abs(it) }.average() - min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2) - } else { - val horizAmplitude = recent.map { abs(it.second) }.average() - val vertVals = verticalBuffer.takeLast(recent.size * 2) - val vertAmplitude = vertVals.map { abs(it) }.average() - min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2) - } - - return ( - amplitudeFactor * 0.4 + - rhythmFactor * 0.2 + - alternationFactor * 0.2 + - isolationFactor * 0.2 - ) - } - - private fun getRequiredExtremes(): Int { - if (movementSpeedIntervals.isEmpty()) return MIN_REQUIRED_EXTREMES - - val avgInterval = movementSpeedIntervals.average() - Log.d(TAG, "Average movement interval: $avgInterval ms") - - return if (avgInterval < FAST_MOVEMENT_THRESHOLD) { - MAX_REQUIRED_EXTREMES - } else { - MIN_REQUIRED_EXTREMES - } - } - - private fun detectGestures(): Boolean? { - val requiredExtremes = getRequiredExtremes() - Log.d(TAG, "Current required extremes: $requiredExtremes") - - if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) { - val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first } - - val confidence = calculateConfidenceScore(allExtremes, isVertical = true) - - Log.d(TAG, "Vertical motion confidence: $confidence (need $minConfidenceThreshold)") - - if (confidence >= minConfidenceThreshold) { - Log.d(TAG, "\"Yes\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)") - return true - } - } - - if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) { - val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first } - - val confidence = calculateConfidenceScore(allExtremes, isVertical = false) - - Log.d(TAG, "Horizontal motion confidence: $confidence (need $minConfidenceThreshold)") - - if (confidence >= minConfidenceThreshold) { - Log.d(TAG, "\"No\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)") - return false - } - } - - return null - } - - private fun clearData() { - horizontalBuffer.clear() - verticalBuffer.clear() - horizontalPeaks.clear() - horizontalTroughs.clear() - verticalPeaks.clear() - verticalTroughs.clear() - peakIntervals.clear() - movementSpeedIntervals.clear() - horizontalIncreasing = null - verticalIncreasing = null - lastPeakTime = 0 - significantMotion = false - lastSignificantMotionTime = 0L - } - - private fun Double.pow(exponent: Int): Double = this.pow(exponent.toDouble()) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt deleted file mode 100644 index 88ab8cf5f..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:Suppress("PrivatePropertyName") - -package me.kavishdevar.librepods.utils - -import android.content.Context -import android.media.AudioAttributes -import android.media.SoundPool -import android.os.Build -import android.os.SystemClock -import android.util.Log -import androidx.annotation.RequiresApi -import me.kavishdevar.librepods.R -import java.util.concurrent.atomic.AtomicBoolean - -class GestureFeedback(context: Context) { - - private val TAG = "GestureFeedback" - - private val soundsLoaded = AtomicBoolean(false) - - private val soundPool = SoundPool.Builder() - .setMaxStreams(3) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) - .build() - ) - .build() - - - private var soundId = 0 - private var confirmYesId = 0 - private var confirmNoId = 0 - - private var lastHorizontalTime = 0L - private var lastLeftTime = 0L - private var lastRightTime = 0L - - private var lastVerticalTime = 0L - private var lastUpTime = 0L - private var lastDownTime = 0L - - private val MIN_TIME_BETWEEN_SOUNDS = 150L - private val MIN_TIME_BETWEEN_DIRECTION = 200L - - private var currentHorizontalStreamId = 0 - private var currentVerticalStreamId = 0 - - - private val LEFT_VOLUME = Pair(1.0f, 0.0f) - private val RIGHT_VOLUME = Pair(0.0f, 1.0f) - private val VERTICAL_VOLUME = Pair(1.0f, 1.0f) - - init { - soundId = soundPool.load(context, R.raw.blip_no, 1) - confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1) - confirmNoId = soundPool.load(context, R.raw.confirm_no, 1) - - soundPool.setOnLoadCompleteListener { _, _, _ -> - Log.d(TAG, "Sounds loaded") - soundsLoaded.set(true) - - soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f) - } - } - - @RequiresApi(Build.VERSION_CODES.R) - fun playDirectional(isVertical: Boolean, value: Double) { - if (!soundsLoaded.get()) { - Log.d(TAG, "Sounds not yet loaded, skipping playback") - return - } - - val now = SystemClock.uptimeMillis() - - if (isVertical) { - val isUp = value > 0 - - if (now - lastVerticalTime < MIN_TIME_BETWEEN_SOUNDS) { - Log.d(TAG, "Skipping vertical sound due to general vertical debounce") - return - } - - if (isUp && now - lastUpTime < MIN_TIME_BETWEEN_DIRECTION) { - Log.d(TAG, "Skipping UP sound due to direction debounce") - return - } - - if (!isUp && now - lastDownTime < MIN_TIME_BETWEEN_DIRECTION) { - Log.d(TAG, "Skipping DOWN sound due to direction debounce") - return - } - - if (currentVerticalStreamId > 0) { - soundPool.stop(currentVerticalStreamId) - } - - val (leftVol, rightVol) = VERTICAL_VOLUME - - currentVerticalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f) - Log.d(TAG, "Playing VERTICAL sound: ${if (isUp) "UP" else "DOWN"} - streamID=$currentVerticalStreamId") - - lastVerticalTime = now - if (isUp) { - lastUpTime = now - } else { - lastDownTime = now - } - } else { - if (now - lastHorizontalTime < MIN_TIME_BETWEEN_SOUNDS) { - Log.d(TAG, "Skipping horizontal sound due to general horizontal debounce") - return - } - - val isRight = value > 0 - - if (isRight && now - lastRightTime < MIN_TIME_BETWEEN_DIRECTION) { - Log.d(TAG, "Skipping RIGHT sound due to direction debounce") - return - } - - if (!isRight && now - lastLeftTime < MIN_TIME_BETWEEN_DIRECTION) { - Log.d(TAG, "Skipping LEFT sound due to direction debounce") - return - } - - if (currentHorizontalStreamId > 0) { - soundPool.stop(currentHorizontalStreamId) - } - - val (leftVol, rightVol) = if (isRight) RIGHT_VOLUME else LEFT_VOLUME - - currentHorizontalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f) - Log.d(TAG, "Playing HORIZONTAL sound: ${if (isRight) "RIGHT" else "LEFT"} - streamID=$currentHorizontalStreamId") - - lastHorizontalTime = now - if (isRight) { - lastRightTime = now - } else { - lastLeftTime = now - } - } - } - - fun playConfirmation(isYes: Boolean) { - if (currentHorizontalStreamId > 0) { - soundPool.stop(currentHorizontalStreamId) - } - if (currentVerticalStreamId > 0) { - soundPool.stop(currentVerticalStreamId) - } - - val soundId = if (isYes) confirmYesId else confirmNoId - if (soundId != 0 && soundsLoaded.get()) { - val streamId = soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f) - Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId") - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt deleted file mode 100644 index ebdf91490..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.utils - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlin.math.roundToInt - -data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f) -data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f) - -object HeadTracking { - private val _orientation = MutableStateFlow(Orientation()) - val orientation = _orientation.asStateFlow() - - private val _acceleration = MutableStateFlow(Acceleration()) - val acceleration = _acceleration.asStateFlow() - - private val calibrationSamples = mutableListOf>() - private var isCalibrated = false - private var o1Neutral = 19000 - private var o2Neutral = 0 - private var o3Neutral = 0 - - private const val CALIBRATION_SAMPLE_COUNT = 10 - private const val ORIENTATION_OFFSET = 5500 - - fun processPacket(packet: ByteArray) { - val o1 = bytesToInt(packet[43], packet[44]) - val o2 = bytesToInt(packet[45], packet[46]) - val o3 = bytesToInt(packet[47], packet[48]) - - val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat() - val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat() - - if (!isCalibrated) { - calibrationSamples.add(Triple(o1, o2, o3)) - if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) { - calibrate() - } - return - } - - val orientation = calculateOrientation(o1, o2, o3) - _orientation.value = orientation - - _acceleration.value = Acceleration(verticalAccel, horizontalAccel) - } - - private fun calibrate() { - if (calibrationSamples.size < 3) return - - // Add offset during calibration - o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt() - o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt() - o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt() - - isCalibrated = true - } - - @Suppress("UnusedVariable") - private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation { - if (!isCalibrated) return Orientation() - - val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral - val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral - val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral - - val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f - val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f - - return Orientation(pitch, yaw) - } - - private fun bytesToInt(b1: Byte, b2: Byte): Int { - return (b2.toInt() shl 8) or (b1.toInt() and 0xFF) - } - - fun reset() { - calibrationSamples.clear() - isCalibrated = false - _orientation.value = Orientation() - _acceleration.value = Acceleration() - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt deleted file mode 100644 index b32892686..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ /dev/null @@ -1,155 +0,0 @@ -package me.kavishdevar.librepods.utils - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.widget.ImageView -import androidx.core.net.toUri -import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam -import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam - -private const val TAG = "LibrePodsHook" - -@SuppressLint("DiscouragedApi", "PrivateApi") -class KotlinModule: XposedModule() { - override fun onModuleLoaded(param: ModuleLoadedParam) { - log(Log.INFO, TAG, "module initialized at :: ${param.processName}") - log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion") - } - - @SuppressLint("UnsafeDynamicallyLoadedCode") - override fun onPackageLoaded(param: PackageLoadedParam) { - log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}") - - if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") { - log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") - try { - if (param.isFirstPackage) { - val abi = android.os.Build.SUPPORTED_ABIS.first() - val soName = "libl2c_fcr_hook.so" - - val candidates = buildList { - add("${moduleApplicationInfo.sourceDir}!/lib/$abi/$soName") - - moduleApplicationInfo.splitSourceDirs?.forEach { split -> - add("$split!/lib/$abi/$soName") - } - } - - var loaded = false - - for (path in candidates) { - try { - log(Log.INFO, TAG, "Trying to load native lib from $path") - System.load(path) - log(Log.INFO, TAG, "Loaded native lib from $path") - loaded = true - break - } catch (e: Throwable) { - log(Log.WARN, TAG, "Failed to load from $path: ${e.message}") - } - } - - if (!loaded) { - log(Log.ERROR, TAG, "Could not load $soName from base or splits") - return - } - - val remotePrefValue = getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false) - log(Log.INFO, TAG, "sdp hook enabled (remote pref): $remotePrefValue") - NativeBridge.setSdpHook(remotePrefValue) - log(Log.INFO, TAG, "Native library loaded successfully") - } - } catch (e: Exception) { - log(Log.ERROR, TAG, "Failed to load native library: ${e.message}") - } - } - - if (param.packageName == "com.google.android.settings") { - hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") - } - - if (param.packageName == "com.android.settings") { - hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") - } - } - - private fun hookSettingsController(param: PackageLoadedParam, className: String) { - log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling") - try { - val headerControllerClass = Class.forName(className, false, param.defaultClassLoader) - val updateIconMethod = headerControllerClass.getDeclaredMethod( - "updateIcon", - ImageView::class.java, - String::class.java - ) - - hook(updateIconMethod).intercept { chain -> - try { - log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}") - val imageView = chain.args[0] as? ImageView - val iconUri = chain.args[1] as? String - - if (imageView == null || iconUri == null) { - return@intercept chain.proceed() - } - - val uri = iconUri.toUri() - if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { - return@intercept chain.proceed() - } - - log(Log.INFO, TAG, "Handling AirPods icon URI: $uri") - - Handler(Looper.getMainLooper()).post { - try { - val context = imageView.context - val packageName = uri.authority ?: return@post - val packageContext = context.createPackageContext( - packageName, - Context.CONTEXT_IGNORE_SECURITY - ) - - val resPath = uri.pathSegments - if (resPath.size >= 2 && resPath[0] == "drawable") { - val resourceName = resPath[1] - val resourceId = packageContext.resources.getIdentifier( - resourceName, "drawable", packageName - ) - - if (resourceId != 0) { - val drawable = packageContext.resources.getDrawable( - resourceId, packageContext.theme - ) - imageView.setImageDrawable(drawable) - imageView.alpha = 1.0f - log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName") - } else { - log(Log.ERROR, TAG, "Resource not found: $resourceName") - } - } - } catch (e: Exception) { - log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}") - } - } - null - } catch (e: Exception) { - log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}") - chain.proceed() - } - } - - log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings") - } catch (e: Exception) { - log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}") - } - } -} - - -object NativeBridge { - external fun setSdpHook(enabled: Boolean) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt deleted file mode 100644 index 400e0ff72..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ /dev/null @@ -1,363 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.utils - -import android.content.SharedPreferences -import android.media.AudioManager -import android.media.AudioPlaybackConfiguration -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.util.Log -import android.view.KeyEvent -import androidx.annotation.RequiresApi -import me.kavishdevar.librepods.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi - -object MediaController { - private var initialVolume: Int? = null - private lateinit var audioManager: AudioManager - var iPausedTheMedia = false - var userPlayedTheMedia = false - private lateinit var sharedPreferences: SharedPreferences - private val handler = Handler(Looper.getMainLooper()) - private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener - - var pausedWhileTakingOver = false - var pausedForOtherDevice = false - - private var lastSelfActionAt: Long = 0L - private const val SELF_ACTION_IGNORE_MS = 800L - private const val PLAYBACK_DEBOUNCE_MS = 300L - private var lastPlaybackCallbackAt: Long = 0L - private var lastKnownIsMusicActive: Boolean? = null - - private const val PAUSED_FOR_OTHER_DEVICE_CLEAR_MS = 500L - private val clearPausedForOtherDeviceRunnable = Runnable { - pausedForOtherDevice = false - Log.d("MediaController", "Cleared pausedForOtherDevice after timeout, resuming normal playback monitoring") - } - - private var relativeVolume: Boolean = false - private var conversationalAwarenessVolume: Int = 2 - private var conversationalAwarenessPauseMusic: Boolean = false - - var recentlyLostOwnership: Boolean = false - - private var lastPlayWithReplay: Boolean = false - private var lastPlayTime: Long = 0L - - fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { - if (this::audioManager.isInitialized) { - return - } - this.audioManager = audioManager - this.sharedPreferences = sharedPreferences - Log.d("MediaController", "Initializing MediaController") - relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt()) - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) - - preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "relative_conversational_awareness_volume" -> { - relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) - } - "conversational_awareness_volume" -> { - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt()) - } - "conversational_awareness_pause_music" -> { - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) - } - } - } - - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - - audioManager.registerAudioPlaybackCallback(cb, null) - } - - val cb = object : AudioManager.AudioPlaybackCallback() { - @RequiresApi(Build.VERSION_CODES.R) - override fun onPlaybackConfigChanged(configs: MutableList?) { - super.onPlaybackConfigChanged(configs) - val now = SystemClock.uptimeMillis() - val isActive = audioManager.isMusicActive - Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia, isActive: $isActive, pausedForOtherDevice: $pausedForOtherDevice, lastKnownIsMusicActive: $lastKnownIsMusicActive") - - if (!isActive && lastPlayWithReplay && now - lastPlayTime < 2500L) { - Log.d("MediaController", "Music paused shortly after play with replay; retrying play") - lastPlayWithReplay = false - sendPlay() - lastKnownIsMusicActive = true - return - } - - if (now - lastPlaybackCallbackAt < PLAYBACK_DEBOUNCE_MS) { - Log.d("MediaController", "Ignoring playback callback due to debounce (${now - lastPlaybackCallbackAt}ms)") - lastPlaybackCallbackAt = now - return - } - lastPlaybackCallbackAt = now - - if (now - lastSelfActionAt < SELF_ACTION_IGNORE_MS) { - Log.d("MediaController", "Ignoring playback callback because it's likely caused by our own action (${now - lastSelfActionAt}ms since last self-action)") - lastKnownIsMusicActive = isActive - return - } - - Log.d("MediaController", "Configs received: ${configs?.size ?: 0} configurations") - val currentActiveContentTypes = configs?.flatMap { config -> - Log.d("MediaController", "Processing config: ${config}, audioAttributes: ${config.audioAttributes}") - config.audioAttributes?.let { attrs -> - val contentType = attrs.contentType - Log.d("MediaController", "Config content type: $contentType") - listOf(contentType) - } ?: run { - Log.d("MediaController", "Config has no audioAttributes") - emptyList() - } - }?.toSet() ?: emptySet() - - Log.d("MediaController", "Current active content types: $currentActiveContentTypes") - - val hasNewMusicOrMovie = currentActiveContentTypes.any { contentType -> - contentType == android.media.AudioAttributes.CONTENT_TYPE_MUSIC || - contentType == android.media.AudioAttributes.CONTENT_TYPE_MOVIE - } - - Log.d("MediaController", "Has new music or movie: $hasNewMusicOrMovie") - - if (pausedForOtherDevice) { - handler.removeCallbacks(clearPausedForOtherDeviceRunnable) - handler.postDelayed(clearPausedForOtherDeviceRunnable, PAUSED_FOR_OTHER_DEVICE_CLEAR_MS) - - if (isActive) { - Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") - if (!recentlyLostOwnership && hasNewMusicOrMovie) { - pausedForOtherDevice = false - userPlayedTheMedia = true - if (!pausedWhileTakingOver) { - ServiceManager.getService()?.takeOver("music") - } - } else { - Log.d("MediaController", "Skipping take-over due to recent ownership loss or no new music/movie") - } - } else { - Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout") - } - - lastKnownIsMusicActive = isActive - return - } - - if (configs != null && !iPausedTheMedia) { - val localMac = ServiceManager.getService()?.localMac ?: return - if (localMac == "") return - ServiceManager.getService()?.aacpManager?.sendMediaInformataion( - localMac, - isActive - ) - Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play") - handler.postDelayed({ - userPlayedTheMedia = audioManager.isMusicActive - if (audioManager.isMusicActive) { - pausedForOtherDevice = false - } - }, 7) - } - - Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") - if (!pausedWhileTakingOver && isActive && hasNewMusicOrMovie) { - if (lastKnownIsMusicActive != true) { - if (!recentlyLostOwnership) { - Log.d("MediaController", "Music/movie is active and not pausedWhileTakingOver; requesting takeOver") - ServiceManager.getService()?.takeOver("music") - } else { - Log.d("MediaController", "Skipping take-over due to recent ownership loss") - } - } - } - - lastKnownIsMusicActive = hasNewMusicOrMovie && isActive - } - } - - @Synchronized - fun getMusicActive(): Boolean { - return audioManager.isMusicActive - } - - @Synchronized - fun sendPlayPause() { - if (audioManager.isMusicActive) { - Log.d("MediaController", "Sending pause because music is active") - sendPause() - } else { - Log.d("MediaController", "Sending play because music is not active") - sendPlay() - } - } - - @Synchronized - fun sendPreviousTrack() { - Log.d("MediaController", "Sending previous track") - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_MEDIA_PREVIOUS - ) - ) - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_MEDIA_PREVIOUS - ) - ) - lastSelfActionAt = SystemClock.uptimeMillis() - } - - @Synchronized - fun sendNextTrack() { - Log.d("MediaController", "Sending next track") - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_MEDIA_NEXT - ) - ) - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_MEDIA_NEXT - ) - ) - lastSelfActionAt = SystemClock.uptimeMillis() - } - - @Synchronized - fun sendPause(force: Boolean = false) { - Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force") - if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) { - iPausedTheMedia = if (force) audioManager.isMusicActive else true - userPlayedTheMedia = false - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_MEDIA_PAUSE - ) - ) - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_MEDIA_PAUSE - ) - ) - lastSelfActionAt = SystemClock.uptimeMillis() - } - } - - @Synchronized - fun sendPlay(replayWhenPaused: Boolean = false, force: Boolean = false) { - Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia, replayWhenPaused: $replayWhenPaused, force: $force") - if (replayWhenPaused) { - lastPlayWithReplay = true - lastPlayTime = SystemClock.uptimeMillis() - } - if (iPausedTheMedia || force) { // very creative, ik. thanks. - Log.d("MediaController", "Sending play and setting userPlayedTheMedia to false") - userPlayedTheMedia = false - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_MEDIA_PLAY - ) - ) - audioManager.dispatchMediaKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_MEDIA_PLAY - ) - ) - lastSelfActionAt = SystemClock.uptimeMillis() - } - if (!audioManager.isMusicActive) { - Log.d("MediaController", "Setting iPausedTheMedia to false") - iPausedTheMedia = false - } - if (pausedWhileTakingOver) { - Log.d("MediaController", "Setting pausedWhileTakingOver to false") - pausedWhileTakingOver = false - } - } - - @Synchronized - fun startSpeaking() { - Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume") - - if (initialVolume == null) { - initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - Log.d("MediaController", "Initial Volume: $initialVolume") - val targetVolume = if (relativeVolume) { - (initialVolume!! * conversationalAwarenessVolume / 100) - } else if (initialVolume!! > (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)) { - (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100) - } else { - initialVolume!! - } - smoothVolumeTransition(initialVolume!!, targetVolume) - if (conversationalAwarenessPauseMusic) { - sendPause(force = true) - } - } - Log.d("MediaController", "Initial Volume: $initialVolume") - } - - @Synchronized - fun stopSpeaking() { - Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume") - if (initialVolume != null) { - smoothVolumeTransition(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC), initialVolume!!) - if (conversationalAwarenessPauseMusic) { - sendPlay() - } - initialVolume = null - } - } - - private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) { - Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume") - val step = if (fromVolume < toVolume) 1 else -1 - val delay = 50L - var currentVolume = fromVolume - - handler.post(object : Runnable { - override fun run() { - if (currentVolume != toVolume) { - currentVolume += step - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0) - handler.postDelayed(this, delay) - } - } - }) - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt deleted file mode 100644 index e5a1e7bdc..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ /dev/null @@ -1,761 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.utils - -import android.content.Context -import android.util.Log -import androidx.compose.runtime.NoLiveLiterals -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.services.ServiceManager -import java.io.BufferedReader -import java.io.File -import java.io.FileOutputStream -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL -import kotlin.io.encoding.ExperimentalEncodingApi - -@NoLiveLiterals -class RadareOffsetFinder(context: Context) { - companion object { - private const val TAG = "RadareOffsetFinder" - private const val RADARE2_URL = "https://github.com/devnoname120/radare2/releases/download/5.9.8-android-aln/radare2-5.9.9-android-aarch64-aln.tar.gz" - private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset" - private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset" - private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset" - private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset" - private const val SDP_OFFSET_PROP = "persist.librepods.sdp_offset" - private const val EXTRACT_DIR = "/" - - private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin" - private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib" - private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox" - - private val LIBRARY_PATHS = listOf( - "/apex/com.android.bt/lib64/libbluetooth_jni.so", - "/apex/com.android.btservices/lib64/libbluetooth_jni.so", - "/system/lib64/libbluetooth_jni.so", - "/system/lib64/libbluetooth_qti.so", - "/system_ext/lib64/libbluetooth_qti.so" - ) - - fun findBluetoothLibraryPath(): String? { - for (path in LIBRARY_PATHS) { - if (File(path).exists()) { - Log.d(TAG, "Found Bluetooth library at $path") - return path - } - } - Log.e(TAG, "Could not find Bluetooth library") - return null - } - - fun clearHookOffsets(): Boolean { - try { - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", - "/system/bin/setprop $HOOK_OFFSET_PROP '' && " + - "/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " + - "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " + - "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP '' &&" + - "/system/bin/setprop $SDP_OFFSET_PROP ''" - )) - val exitCode = process.waitFor() - - if (exitCode == 0) { - Log.d(TAG, "Successfully cleared hook offset properties") - return true - } else { - Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode") - } - } catch (e: Exception) { - Log.e(TAG, "Error clearing hook offset properties", e) - } - return false - } - - fun clearSdpOffset(): Boolean { - try { - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''" - )) - val exitCode = process.waitFor() - - if (exitCode == 0) { - Log.d(TAG, "Successfully cleared SDP offset property") - return true - } else { - Log.e(TAG, "Failed to clear SDP offset property, exit code: $exitCode") - } - } catch (e: Exception) { - Log.e(TAG, "Error clearing SDP offset property", e) - } - return false - } - - fun isSdpOffsetAvailable(): Boolean { - val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy - if (sharedPreferences?.getBoolean("skip_setup", false) == true) { - Log.d(TAG, "Setup skipped, returning true for SDP offset.") - return true - } - try { - val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val propValue = reader.readLine() - process.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "SDP offset property exists: $propValue") - return true - } - } catch (e: Exception) { - Log.e(TAG, "Error checking if SDP offset property exists", e) - } - - Log.d(TAG, "No SDP offset available") - return false - } - } - - private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz") - - private val _progressState = MutableStateFlow(ProgressState.Idle) - val progressState: StateFlow = _progressState - - sealed class ProgressState { - object Idle : ProgressState() - object CheckingExisting : ProgressState() - object Downloading : ProgressState() - data class DownloadProgress(val progress: Float) : ProgressState() - object Extracting : ProgressState() - object MakingExecutable : ProgressState() - object FindingOffset : ProgressState() - object SavingOffset : ProgressState() - object Cleaning : ProgressState() - data class Error(val message: String) : ProgressState() - data class Success(val offset: Long) : ProgressState() - } - - - fun isHookOffsetAvailable(): Boolean { - Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString()) - if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) { - Log.d(TAG, "Setup skipped, returning true.") - return true - } - _progressState.value = ProgressState.CheckingExisting - try { - val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", HOOK_OFFSET_PROP)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val propValue = reader.readLine() - process.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "Hook offset property exists: $propValue") - _progressState.value = ProgressState.Idle - return true - } - } catch (e: Exception) { - Log.e(TAG, "Error checking if offset property exists", e) - _progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}") - } - - Log.d(TAG, "No hook offset available") - _progressState.value = ProgressState.Idle - return false - } - - suspend fun setupAndFindOffset(): Boolean { - val offset = findOffset() - return offset > 0 - } - - suspend fun findOffset(): Long = withContext(Dispatchers.IO) { - try { - _progressState.value = ProgressState.Downloading - if (!downloadRadare2TarballIfNeeded()) { - _progressState.value = ProgressState.Error("Failed to download radare2 tarball") - Log.e(TAG, "Failed to download radare2 tarball") - return@withContext 0L - } - - _progressState.value = ProgressState.Extracting - if (!extractRadare2Tarball()) { - _progressState.value = ProgressState.Error("Failed to extract radare2 tarball") - Log.e(TAG, "Failed to extract radare2 tarball") - return@withContext 0L - } - - _progressState.value = ProgressState.MakingExecutable - if (!makeExecutable()) { - _progressState.value = ProgressState.Error("Failed to make binaries executable") - Log.e(TAG, "Failed to make binaries executable") - return@withContext 0L - } - - _progressState.value = ProgressState.FindingOffset - val offset = findFunctionOffset() - if (offset == 0L) { - _progressState.value = ProgressState.Error("Failed to find function offset") - Log.e(TAG, "Failed to find function offset") - return@withContext 0L - } - - _progressState.value = ProgressState.SavingOffset - if (!saveOffset(offset)) { - _progressState.value = ProgressState.Error("Failed to save offset") - Log.e(TAG, "Failed to save offset") - return@withContext 0L - } - - _progressState.value = ProgressState.Cleaning - cleanupExtractedFiles() - - _progressState.value = ProgressState.Success(offset) - return@withContext offset - - } catch (e: Exception) { - _progressState.value = ProgressState.Error("Error: ${e.message}") - Log.e(TAG, "Error in findOffset", e) - return@withContext 0L - } - } - - private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) { - if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) { - Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}") - return@withContext true - } - - try { - val url = URL(RADARE2_URL) - val connection = url.openConnection() as HttpURLConnection - connection.connectTimeout = 60000 - connection.readTimeout = 60000 - - val contentLength = connection.contentLength.toFloat() - val inputStream = connection.inputStream - val outputStream = FileOutputStream(radare2TarballFile) - - val buffer = ByteArray(4096) - var bytesRead: Int - var totalBytesRead = 0L - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - if (contentLength > 0) { - val progress = totalBytesRead.toFloat() / contentLength - _progressState.value = ProgressState.DownloadProgress(progress) - } - } - - outputStream.close() - inputStream.close() - - Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}") - return@withContext true - } catch (e: Exception) { - Log.e(TAG, "Failed to download radare2 tarball", e) - return@withContext false - } - } - - private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) { - try { - val isAlreadyExtracted = checkIfAlreadyExtracted() - - if (isAlreadyExtracted) { - Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction") - return@withContext true - } - - Log.d(TAG, "Removing existing extract directory") - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - - Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - - Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR") - - val process = Runtime.getRuntime().exec( - arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR") - ) - - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "Extract output: $line") - } - - while (errorReader.readLine().also { line = it } != null) { - Log.e(TAG, "Extract error: $line") - } - - val exitCode = process.waitFor() - if (exitCode == 0) { - Log.d(TAG, "Extraction completed successfully") - return@withContext true - } else { - Log.e(TAG, "Extraction failed with exit code $exitCode") - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "Failed to extract radare2", e) - return@withContext false - } - } - - private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) { - try { - val checkDirProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'") - ) - val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists" - checkDirProcess.waitFor() - - if (!dirExists) { - Log.d(TAG, "Extract directory doesn't exist, need to extract") - return@withContext false - } - - val tarProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}") - ) - val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines() - .filter { it.isNotEmpty() } - .map { it.trim() } - .toSet() - tarProcess.waitFor() - - if (tarFiles.isEmpty()) { - Log.e(TAG, "Failed to get file list from tarball") - return@withContext false - } - - val findProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort") - ) - val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines() - .filter { it.isNotEmpty() } - .map { it.trim() } - .toSet() - findProcess.waitFor() - - if (extractedFiles.isEmpty()) { - Log.d(TAG, "No files found in extract directory, need to extract") - return@withContext false - } - - for (tarFile in tarFiles) { - if (tarFile.endsWith("/")) continue - - val filePathInExtractDir = "$EXTRACT_DIR/$tarFile" - val fileCheckProcess = Runtime.getRuntime().exec( - arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'") - ) - val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists" - fileCheckProcess.waitFor() - - if (!fileExists) { - Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory") - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - return@withContext false - } - } - - Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory") - return@withContext true - } catch (e: Exception) { - Log.e(TAG, "Error checking extraction status", e) - return@withContext false - } - } - - private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH") - val chmod1Result = Runtime.getRuntime().exec( - arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH") - ).waitFor() - - Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH") - - val chmod2Result = Runtime.getRuntime().exec( - arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH") - ).waitFor() - - if (chmod1Result == 0 && chmod2Result == 0) { - Log.d(TAG, "Successfully made binaries executable") - return@withContext true - } else { - Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result") - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "Error making binaries executable", e) - return@withContext false - } - } - - private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) { - val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L - var offset = 0L - - try { - @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() - val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim() - val envSetup = """ - export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" - export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" - """.trimIndent() - - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("fcr_chk_chan") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - -// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup) -// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup) -// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup) - - // findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it. - - } catch (e: Exception) { - Log.e(TAG, "Failed to find function offset", e) - return@withContext 0L - } - - if (offset == 0L) { - Log.e(TAG, "Failed to extract function offset from output, aborting") - return@withContext 0L - } - - Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}") - return@withContext offset - } - - private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2cu_process_our_cfg_req") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e) - } - } - - private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2c_csm_config") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2c_csm_config offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2c_csm_config offset", e) - } - } - - private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("l2cu_send_peer_info_req") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e) - } - } - - private suspend fun findAndSaveSdpOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) { - try { - val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord" - Log.d(TAG, "Running command: $command") - - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val errorReader = BufferedReader(InputStreamReader(process.errorStream)) - - var line: String? - var offset = 0L - - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 output: $line") - if (line?.contains("DmSetLocalDiRecord") == true) { - val parts = line.split(" ") - if (parts.isNotEmpty() && parts[0].startsWith("0x")) { - offset = parts[0].substring(2).toLong(16) - Log.d(TAG, "Found DmSetLocalDiRecord offset at ${parts[0]}") - break - } - } - } - - while (errorReader.readLine().also { line = it } != null) { - Log.d(TAG, "rabin2 error: $line") - } - - val exitCode = process.waitFor() - if (exitCode != 0) { - Log.e(TAG, "rabin2 command failed with exit code $exitCode") - } - - if (offset > 0L) { - val hexString = "0x${offset.toString(16)}" - Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString" - )).waitFor() - Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to find or save DmSetLocalDiRecord offset", e) - } - } - - private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) { - try { - val hexString = "0x${offset.toString(16)}" - Log.d(TAG, "Saving offset to system property: $hexString") - - val process = Runtime.getRuntime().exec(arrayOf( - "su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString" - )) - - val exitCode = process.waitFor() - if (exitCode == 0) { - val verifyProcess = Runtime.getRuntime().exec(arrayOf( - "/system/bin/getprop", HOOK_OFFSET_PROP - )) - val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine() - verifyProcess.waitFor() - - if (propValue != null && propValue.isNotEmpty()) { - Log.d(TAG, "Successfully saved offset to system property: $propValue") - return@withContext true - } else { - Log.e(TAG, "Property was set but couldn't be verified") - } - } else { - Log.e(TAG, "Failed to set property, exit code: $exitCode") - } - return@withContext false - } catch (e: Exception) { - Log.e(TAG, "Failed to save offset", e) - return@withContext false - } - } - - private fun cleanupExtractedFiles() { - try { - Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() - Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip") - } catch (e: Exception) { - Log.e(TAG, "Failed to cleanup extracted files", e) - } - } - - suspend fun findSdpOffset(): Boolean = withContext(Dispatchers.IO) { - try { - _progressState.value = ProgressState.Downloading - if (!downloadRadare2TarballIfNeeded()) { - _progressState.value = ProgressState.Error("Failed to download radare2 tarball") - Log.e(TAG, "Failed to download radare2 tarball") - return@withContext false - } - - _progressState.value = ProgressState.Extracting - if (!extractRadare2Tarball()) { - _progressState.value = ProgressState.Error("Failed to extract radare2 tarball") - Log.e(TAG, "Failed to extract radare2 tarball") - return@withContext false - } - - _progressState.value = ProgressState.MakingExecutable - if (!makeExecutable()) { - _progressState.value = ProgressState.Error("Failed to make binaries executable") - Log.e(TAG, "Failed to make binaries executable") - return@withContext false - } - - _progressState.value = ProgressState.FindingOffset - val libraryPath = findBluetoothLibraryPath() - if (libraryPath == null) { - _progressState.value = ProgressState.Error("Failed to find Bluetooth library") - Log.e(TAG, "Failed to find Bluetooth library") - return@withContext false - } - - @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() - val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim() - val envSetup = """ - export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" - export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" - """.trimIndent() - - findAndSaveSdpOffset(libraryPath, envSetup) - - _progressState.value = ProgressState.Cleaning - cleanupExtractedFiles() - - _progressState.value = ProgressState.Success(0L) - return@withContext true - - } catch (e: Exception) { - _progressState.value = ProgressState.Error("Error: ${e.message}") - Log.e(TAG, "Error in findSdpOffset", e) - return@withContext false - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt deleted file mode 100644 index 57741a81c..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - LibrePods - AirPods liberated from Apple’s ecosystem - Copyright (C) 2025 LibrePods contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -package me.kavishdevar.librepods.utils - -import android.content.SharedPreferences -import android.os.Build -import androidx.core.content.edit - -fun isSupported(sharedPreferences: SharedPreferences): Boolean { - if (Build.VERSION.SDK_INT >= 37) return true - - val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false) - if (isBypassFlagActive) return true - - val isPixel = Build.MANUFACTURER.lowercase() == "google" - val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme") - - if (isPixel && Build.VERSION.SDK_INT == 36) { - return Build.ID.startsWith("CP1A") - } else if (isOppoFamily) { - return Build.VERSION.SDK_INT >= 36 - } - return false -} - -fun bypassDeviceCheck(sharedPreferences: SharedPreferences) { - sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt deleted file mode 100644 index 27a4ffc98..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.kavishdevar.librepods.utils - -import io.github.libxposed.service.XposedService - -object XposedServiceHolder { - var service: XposedService? = null -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedState.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedState.kt deleted file mode 100644 index 284e5e656..000000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package me.kavishdevar.librepods.utils - -object XposedState { - var isAvailable: Boolean = false - var bluetoothScopeEnabled: Boolean = false -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPStreamFramer.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPStreamFramer.kt new file mode 100644 index 000000000..3430146c6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPStreamFramer.kt @@ -0,0 +1,59 @@ +package me.kavishdevar.librepods.wear.bluetooth + +/** + * Conservative AACP stream framer. + * + * Bluetooth reads are not packet boundaries. We only emit frames whose + * lengths are verified by the currently implemented protocol layouts. + * Unknown frames stay buffered until a verified frame can be extracted; + * their bytes are never assigned a guessed length. + */ +class AACPStreamFramer { + private val header = byteArrayOf(0x04, 0x00, 0x04, 0x00) + private val handshakeAck = byteArrayOf(0x01, 0x00, 0x04, 0x00) + private var buffer = ByteArray(0) + + fun reset() { + buffer = ByteArray(0) + } + + fun append(bytes: ByteArray, onFrame: (ByteArray) -> Unit) { + if (bytes.isEmpty()) return + buffer += bytes + extract(onFrame) + } + + private fun extract(onFrame: (ByteArray) -> Unit) { + while (buffer.isNotEmpty()) { + val length = verifiedLength(buffer) ?: return + if (length <= 0 || length > buffer.size) return + onFrame(buffer.copyOfRange(0, length)) + buffer = buffer.copyOfRange(length, buffer.size) + } + } + + private fun verifiedLength(data: ByteArray): Int? { + if (data.size >= 4 && data.copyOfRange(0, 4).contentEquals(handshakeAck)) return 4 + if (data.size < 5) return null + if (!data.copyOfRange(0, 4).contentEquals(header)) { + // Do not invent a boundary for an unknown prefix. Keep it for + // diagnostics instead of feeding a partial frame to AACPManager. + return null + } + + return when (data[4].toInt() and 0xFF) { + 0x04 -> { + if (data.size < 7) return null + val count = data[6].toInt() and 0xFF + if (count > 3) return null + val length = 7 + count * 5 + if (data.size >= length) length else null + } + 0x06 -> if (data.size >= 8) 8 else null + else -> null + } + } + + /** Bytes that are still buffered and intentionally not framed. */ + fun bufferedBytes(): ByteArray = buffer.copyOf() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPTransportBridge.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPTransportBridge.kt new file mode 100644 index 000000000..4eb478aff --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AACPTransportBridge.kt @@ -0,0 +1,31 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.util.Log +import me.kavishdevar.librepods.bluetooth.AACPManager + +/** + * Transitional bridge between the new Wear-owned transport and the inherited + * AACP implementation. It deliberately does not implement packet framing; + * AACPManager remains the single source of truth for packet construction and + * parsing until its socket dependency is removed completely. + */ +class AACPTransportBridge( + private val transport: AirPodsProtocolTransport, + private val manager: AACPManager, +) { + private val tag = "AACPTransportBridge" + + /** Sends one already-framed AACP packet through the Wear-owned socket. */ + fun send(packet: ByteArray): Boolean = runCatching { + synchronized(transport.aacpOutput) { + transport.aacpOutput.write(packet) + transport.aacpOutput.flush() + } + true + }.onFailure { Log.e(tag, "Failed to send AACP packet", it) }.getOrDefault(false) + + /** Delivers an already-framed packet to the inherited AACP parser. */ + fun receive(packet: ByteArray) { + manager.receivePacket(packet) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionSession.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionSession.kt new file mode 100644 index 000000000..b76a625d9 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionSession.kt @@ -0,0 +1,184 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.IOException +import java.util.UUID + +/** + * Owns the direct AirPods L2CAP transport for the Wear application. + * + * AACP is the first transport we bring up because battery/ear state and + * control notifications are carried on the classic L2CAP PSM 0x1001. + * ATT remains an optional second transport for later features. + */ +class AirPodsConnectionSession( + private val adapter: BluetoothAdapter, +) : AirPodsProtocolTransport { + enum class State { IDLE, CONNECTING, CONNECTED, DISCONNECTING, FAILED } + + companion object { + const val AACP_PSM = 0x1001 + private val FALLBACK_UUID = ParcelUuid(UUID(0L, 0L)) + } + + private val mutableState = MutableStateFlow(State.IDLE) + val state: StateFlow = mutableState.asStateFlow() + + var aacpSocket: BluetoothSocket? = null + private set + var attSocket: BluetoothSocket? = null + private set + + override val aacpInput get() = requireSocket(aacpSocket).inputStream + override val aacpOutput get() = requireSocket(aacpSocket).outputStream + override val attInput get() = requireSocket(attSocket).inputStream + override val attOutput get() = requireSocket(attSocket).outputStream + + @SuppressLint("MissingPermission") + @Synchronized + fun connectAacp(device: BluetoothDevice) { + if (mutableState.value == State.CONNECTING || mutableState.value == State.CONNECTED) return + mutableState.value = State.CONNECTING + closeSockets() + + try { + adapter.cancelDiscovery() + val socket = createL2capSocket(device, FALLBACK_UUID, AACP_PSM) + socket.connect() + aacpSocket = socket + mutableState.value = State.CONNECTED + Log.i("AirPodsConnection", "AACP L2CAP connected to ${device.address} on PSM 0x1001") + } catch (error: Throwable) { + closeSockets() + mutableState.value = State.FAILED + Log.e("AirPodsConnection", "AACP L2CAP connection failed", error) + throw error + } + } + + @SuppressLint("MissingPermission") + @Synchronized + fun connect( + device: BluetoothDevice, + aacpUuid: ParcelUuid, + aacpPsm: Int, + attUuid: ParcelUuid, + attPsm: Int, + ) { + if (mutableState.value == State.CONNECTING || mutableState.value == State.CONNECTED) return + mutableState.value = State.CONNECTING + closeSockets() + + try { + adapter.cancelDiscovery() + val newAacp = createL2capSocket(device, aacpUuid, aacpPsm) + newAacp.connect() + aacpSocket = newAacp + + try { + val newAtt = createL2capSocket(device, attUuid, attPsm) + newAtt.connect() + attSocket = newAtt + } catch (attError: IOException) { + closeSockets() + throw attError + } + + mutableState.value = State.CONNECTED + } catch (error: Throwable) { + closeSockets() + mutableState.value = State.FAILED + throw error + } + } + + @Synchronized + fun reconnectAacp(device: BluetoothDevice) { + disconnect() + connectAacp(device) + } + + @Synchronized + fun reconnect( + device: BluetoothDevice, + aacpUuid: ParcelUuid, + aacpPsm: Int, + attUuid: ParcelUuid, + attPsm: Int, + ) { + disconnect() + connect(device, aacpUuid, aacpPsm, attUuid, attPsm) + } + + @Synchronized + fun disconnect() { + if (mutableState.value == State.IDLE) return + mutableState.value = State.DISCONNECTING + closeSockets() + mutableState.value = State.IDLE + } + + @Synchronized + fun close() = disconnect() + + private fun requireSocket(socket: BluetoothSocket?): BluetoothSocket = + socket ?: throw IllegalStateException("AirPods transport is not connected") + + private fun closeSockets() { + runCatching { aacpSocket?.close() } + runCatching { attSocket?.close() } + aacpSocket = null + attSocket = null + } + + @Suppress("DEPRECATION") + private fun createL2capSocket( + device: BluetoothDevice, + uuid: ParcelUuid, + psm: Int, + ): BluetoothSocket { + // Modern Android exposes a public L2CAP channel API. Prefer it over + // hidden BluetoothSocket constructors; keep the reflection fallback + // for Wear builds/devices where the public channel is unavailable. + if (psm == AACP_PSM) { + runCatching { + val socket = device.createL2capChannel(psm) + Log.d("AirPodsConnection", "Using public createL2capChannel for PSM 0x${psm.toString(16)}") + return socket + }.onFailure { + Log.d("AirPodsConnection", "Public L2CAP channel API unavailable: ${it.message}") + } + } + + val type = 3 + val constructorSpecs: List> = listOf( + arrayOf(adapter, device, type, true, true, psm, uuid), + arrayOf(device, type, true, true, psm, uuid), + arrayOf(device, type, 1, true, true, psm, uuid), + arrayOf(type, 1, true, true, device, psm, uuid), + arrayOf(type, true, true, device, psm, uuid), + ) + var lastException: Exception? = null + for ((index, params) in constructorSpecs.withIndex()) { + try { + val parameterTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() + val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*parameterTypes) + constructor.isAccessible = true + Log.d("AirPodsConnection", "Using L2CAP socket constructor #${index + 1} for PSM 0x${psm.toString(16)}") + return constructor.newInstance(*params) as BluetoothSocket + } catch (error: Exception) { + lastException = error + Log.d("AirPodsConnection", "L2CAP constructor #${index + 1} unavailable: ${error.message}") + } + } + throw lastException ?: IllegalStateException("No compatible L2CAP BluetoothSocket constructor") + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionTarget.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionTarget.kt new file mode 100644 index 000000000..c53298b1b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsConnectionTarget.kt @@ -0,0 +1,19 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.bluetooth.BluetoothDevice +import android.os.ParcelUuid + +/** + * Complete transport target produced by discovery/protocol negotiation. + * + * UUIDs and PSMs are intentionally data, not constants here: AirPods model + * and Android transport compatibility must be resolved by the discovery + * layer before a session is started. + */ +data class AirPodsConnectionTarget( + val device: BluetoothDevice, + val aacpUuid: ParcelUuid, + val aacpPsm: Int, + val attUuid: ParcelUuid, + val attPsm: Int, +) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolDiagnostics.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolDiagnostics.kt new file mode 100644 index 000000000..146473df7 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolDiagnostics.kt @@ -0,0 +1,98 @@ +package me.kavishdevar.librepods.wear.bluetooth + +/** + * Small, dependency-free AACP frame decoder used by the Wear transport. + * Unknown packet layouts are never guessed; they remain available as hex. + */ +object AirPodsProtocolDiagnostics { + private val header = byteArrayOf(0x04, 0x00, 0x04, 0x00) + + data class Frame( + val opcode: Int?, + val payload: ByteArray, + val raw: ByteArray, + ) + + data class BatteryComponent( + val type: Component, + val level: Int, + val charging: Boolean, + val connected: Boolean, + ) + + enum class Component(val wireValue: Int) { HEADSET(0x01), RIGHT(0x02), LEFT(0x04), CASE(0x08), UNKNOWN(-1) } + + fun isHeader(packet: ByteArray): Boolean = + packet.size >= 4 && packet.copyOfRange(0, 4).contentEquals(header) + + fun decode(packet: ByteArray): Frame? { + if (!isHeader(packet) || packet.size < 5) return null + return Frame(packet[4].toInt() and 0xFF, packet.copyOfRange(5, packet.size), packet.copyOf()) + } + + /** + * AACP battery response: header + opcode + reserved + count + N*5 bytes. + * Each entry is type, 0x01, level/status, status, 0x01. + * The layout is validated before returning anything to the UI layer. + */ + fun parseBattery(packet: ByteArray): List? { + val frame = decode(packet) ?: return null + if (frame.opcode != 0x04 || packet.size < 7) return null + val count = packet[6].toInt() and 0xFF + if (count > 3 || packet.size != 7 + 5 * count) return null + + val result = ArrayList(count) + repeat(count) { index -> + val offset = 7 + index * 5 + if ((packet[offset + 1].toInt() and 0xFF) != 0x01 || + (packet[offset + 4].toInt() and 0xFF) != 0x01) return null + val typeValue = packet[offset].toInt() and 0xFF + val type = Component.entries.firstOrNull { it.wireValue == typeValue } ?: Component.UNKNOWN + val level = (packet[offset + 2].toInt() and 0xFF).coerceIn(0, 100) + val status = packet[offset + 3].toInt() and 0xFF + result += BatteryComponent( + type = type, + level = level, + charging = status == 0x01, + connected = status != 0x04, + ) + } + return result + } + + /** Ear-detection packets are fixed 8-byte AACP frames. 0x00 means in-ear. */ + fun parseEarDetection(packet: ByteArray): Pair? { + val frame = decode(packet) ?: return null + if (frame.opcode != 0x06 || packet.size != 8) return null + fun inEar(value: Int): Boolean? = when (value) { + 0x00 -> true + 0x01, 0x02 -> false + else -> null + } + val left = inEar(packet[6].toInt() and 0xFF) ?: return null + val right = inEar(packet[7].toInt() and 0xFF) ?: return null + return left to right + } + + fun opcodeName(opcode: Int?): String = when (opcode) { + 0x04 -> "BATTERY_INFO" + 0x06 -> "EAR_DETECTION" + 0x09 -> "CONTROL_COMMAND" + 0x0F -> "REQUEST_NOTIFICATIONS" + 0x17 -> "HEADTRACKING" + 0x19 -> "STEM_PRESS" + 0x1A -> "RENAME" + 0x1D -> "INFORMATION" + 0x2E -> "CONNECTED_DEVICES" + 0x30 -> "PROXIMITY_KEYS_REQ" + 0x31 -> "PROXIMITY_KEYS_RSP" + 0x4B -> "CONVERSATION_AWARENESS" + 0x4D -> "SET_FEATURE_FLAGS" + 0x53 -> "HEADPHONE_ACCOMMODATION" + 0x63 -> "CUSTOM_EQ" + else -> "UNKNOWN" + } + + fun hex(bytes: ByteArray, maxBytes: Int = 256): String = + bytes.take(maxBytes).joinToString(" ") { "%02X".format(it.toInt() and 0xFF) } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolTransport.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolTransport.kt new file mode 100644 index 000000000..d118e859d --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsProtocolTransport.kt @@ -0,0 +1,17 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import java.io.InputStream +import java.io.OutputStream + +/** + * Protocol-neutral view of an active AirPods transport. + * + * ATT and AACP can consume this interface without knowing how Android created + * or owns the underlying Bluetooth sockets. + */ +interface AirPodsProtocolTransport { + val aacpInput: InputStream + val aacpOutput: OutputStream + val attInput: InputStream + val attOutput: OutputStream +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsReconnectManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsReconnectManager.kt new file mode 100644 index 000000000..3da233b18 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsReconnectManager.kt @@ -0,0 +1,52 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow + +/** + * Small bounded reconnect coordinator. It owns retry timing only; transport + * and protocol state remain inside the connection session. + */ +class AirPodsReconnectManager( + private val scope: CoroutineScope, + private val session: AirPodsConnectionSession, + private val maxAttempts: Int = 5, +) { + private var job: Job? = null + + fun start(target: AirPodsConnectionTarget) { + job?.cancel() + job = scope.launch { + var attempt = 0 + var delayMs = 1_000L + + while (attempt < maxAttempts) { + attempt++ + try { + session.connect( + device = target.device, + aacpUuid = target.aacpUuid, + aacpPsm = target.aacpPsm, + attUuid = target.attUuid, + attPsm = target.attPsm, + ) + return@launch + } catch (_: Throwable) { + if (attempt >= maxAttempts) return@launch + delay(delayMs) + delayMs = (delayMs * 2).coerceAtMost(16_000L) + } + } + } + } + + fun cancel() { + job?.cancel() + job = null + } + + fun state(): StateFlow = session.state +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsTransportStatus.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsTransportStatus.kt new file mode 100644 index 000000000..084dac310 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/AirPodsTransportStatus.kt @@ -0,0 +1,15 @@ +package me.kavishdevar.librepods.wear.bluetooth + +/** + * Small diagnostic snapshot used by the Wear UI and reconnect logic. + * Protocol handshake state intentionally stays separate from socket state. + */ +data class AirPodsTransportStatus( + val state: AirPodsConnectionSession.State, + val aacpOpen: Boolean, + val attOpen: Boolean, + val lastError: String? = null, +) { + val transportReady: Boolean + get() = state == AirPodsConnectionSession.State.CONNECTED && aacpOpen && attOpen +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/PairedDeviceRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/PairedDeviceRepository.kt new file mode 100644 index 000000000..a106ee758 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/PairedDeviceRepository.kt @@ -0,0 +1,39 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.content.Context + +/** + * System Bluetooth paired-device source. + * Pairing and discovery are delegated to Wear OS. + * LibrePods only handles connected device communication. + */ +class PairedDeviceRepository(private val context: Context) { + + @SuppressLint("MissingPermission") + fun getBondedDevices(): List { + val adapter = context + .getSystemService(BluetoothManager::class.java) + ?.adapter + ?: return emptyList() + + return adapter.bondedDevices + .orEmpty() + .sortedByDescending { isLikelyAirPods(it) } + } + + @SuppressLint("MissingPermission") + fun getBondedAirPods(): List { + return getBondedDevices().filter { isLikelyAirPods(it) } + } + + @SuppressLint("MissingPermission") + private fun isLikelyAirPods(device: BluetoothDevice): Boolean { + val name = device.name.orEmpty() + return name.contains("AirPods", true) || + name.contains("Pods", true) || + name.contains("Beats", true) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothConnection.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothConnection.kt new file mode 100644 index 000000000..b0377521d --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothConnection.kt @@ -0,0 +1,53 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.content.Context +import kotlinx.coroutines.flow.StateFlow +import java.io.InputStream +import java.io.OutputStream + +/** + * Wear Bluetooth facade. L2CAP ownership is delegated to + * [AirPodsConnectionSession]; this class exposes the active protocol streams. + */ +class WearBluetoothConnection(context: Context) : AirPodsProtocolTransport { + @Suppress("UNUSED_PARAMETER") + private val appContext = context.applicationContext + private var session: AirPodsConnectionSession? = null + + fun attachSession(connectionSession: AirPodsConnectionSession) { + session = connectionSession + } + + fun connectionState(): StateFlow? = session?.state + + /** Connect the AACP classic L2CAP channel on PSM 0x1001. */ + @SuppressLint("MissingPermission") + fun connectAacp(device: BluetoothDevice) { + session?.connectAacp(device) + ?: error("AirPods connection session is not attached") + } + + override val aacpInput: InputStream + get() = session?.aacpInput + ?: error("AirPods AACP transport is not connected") + + override val aacpOutput: OutputStream + get() = session?.aacpOutput + ?: error("AirPods AACP transport is not connected") + + override val attInput: InputStream + get() = session?.attInput + ?: error("AirPods ATT transport is not connected") + + override val attOutput: OutputStream + get() = session?.attOutput + ?: error("AirPods ATT transport is not connected") + + fun aacpInput() = aacpInput + + fun close() { + session?.close() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothScanner.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothScanner.kt new file mode 100644 index 000000000..25d0f2c25 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/bluetooth/WearBluetoothScanner.kt @@ -0,0 +1,134 @@ +package me.kavishdevar.librepods.wear.bluetooth + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import me.kavishdevar.librepods.wear.core.AirPodsDevice + +/** BLE diagnostics plus a system-paired-device view. Pairing itself is owned by Wear OS. */ +class WearBluetoothScanner(context: Context) { + private val appContext = context.applicationContext + private val bluetoothManager = appContext.getSystemService(BluetoothManager::class.java) + private val adapter: BluetoothAdapter? get() = bluetoothManager?.adapter + private val scanner: BluetoothLeScanner? get() = adapter?.bluetoothLeScanner + + private val mutableDevices = MutableStateFlow>(emptyList()) + val devices: StateFlow> = mutableDevices.asStateFlow() + private val mutableScanning = MutableStateFlow(false) + val scanning: StateFlow = mutableScanning.asStateFlow() + private val mutableScanError = MutableStateFlow(null) + val scanError: StateFlow = mutableScanError.asStateFlow() + private val mutableCallbackCount = MutableStateFlow(0L) + val callbackCount: StateFlow = mutableCallbackCount.asStateFlow() + private val mutableLog = MutableStateFlow("No paired devices") + val log: StateFlow = mutableLog.asStateFlow() + + private val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + mutableCallbackCount.value++ + addResult(result) + } + override fun onBatchScanResults(results: MutableList) { + mutableCallbackCount.value += results.size + results.forEach(::addResult) + } + override fun onScanFailed(errorCode: Int) { + mutableScanError.value = errorCode + mutableScanning.value = false + mutableLog.value = "Scan failed: $errorCode" + } + } + + fun isSupported(): Boolean = adapter != null + fun isEnabled(): Boolean = adapter?.isEnabled == true + + @SuppressLint("MissingPermission") + private fun addResult(result: ScanResult) { + if (!hasScanPermission()) return + val device = result.device + val record = result.scanRecord + val name = runCatching { device.name }.getOrNull()?.takeIf { it.isNotBlank() } + ?: record?.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown BLE device" + val ids = record?.manufacturerSpecificData?.let { data -> (0 until data.size()).map { data.keyAt(it) } }.orEmpty() + val item = AirPodsDevice( + name = name, address = device.address, rssi = result.rssi, + bonded = isBonded(device), appleManufacturer = ids.any { it == APPLE_COMPANY_ID }, + serviceUuids = record?.serviceUuids.orEmpty().map { it.uuid.toString() } + ) + val previous = mutableDevices.value.firstOrNull { it.address == item.address } + mutableDevices.value = (mutableDevices.value.filterNot { it.address == item.address } + item) + .sortedWith(compareByDescending { it.appleManufacturer }.thenByDescending { it.bonded }.thenByDescending { it.rssi ?: -127 }) + if (previous == null) mutableLog.value = "Found ${if (item.appleManufacturer) "Apple BLE" else name} (${result.rssi} dBm)" + } + + @SuppressLint("MissingPermission") + private fun isBonded(device: BluetoothDevice): Boolean = + hasConnectPermission() && runCatching { device.bondState == BluetoothDevice.BOND_BONDED }.getOrDefault(false) + + /** Refresh only the system-paired list; no custom discovery is involved. */ + @SuppressLint("MissingPermission") + fun refreshPairedDevices() { + if (!hasConnectPermission()) return + stopScan() + mutableDevices.value = bondedDevices() + mutableScanError.value = null + mutableLog.value = if (mutableDevices.value.isEmpty()) "No paired devices — use Pair in system settings" else "${mutableDevices.value.size} paired device(s)" + } + + @SuppressLint("MissingPermission") + fun startScan() { + if (!hasScanPermission()) { mutableScanError.value = ERROR_PERMISSION; mutableLog.value = "BLUETOOTH_SCAN permission missing"; return } + if (!isSupported()) { mutableScanError.value = ERROR_UNSUPPORTED; mutableLog.value = "Bluetooth unavailable"; return } + if (!isEnabled()) { mutableScanError.value = ERROR_DISABLED; mutableLog.value = "Bluetooth disabled"; return } + if (mutableScanning.value) return + mutableScanError.value = null + mutableCallbackCount.value = 0 + mutableDevices.value = bondedDevices().distinctBy { it.address } + val bleScanner = scanner ?: run { mutableScanError.value = ERROR_SCANNER; return } + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + runCatching { bleScanner.startScan(null, settings, callback) } + .onSuccess { mutableScanning.value = true; mutableLog.value = "Scanning BLE…" } + .onFailure { mutableScanError.value = ERROR_START; mutableLog.value = "Scan start failed: ${it.message ?: it.javaClass.simpleName}" } + } + + @SuppressLint("MissingPermission") + fun stopScan() { + if (hasScanPermission()) runCatching { scanner?.stopScan(callback) } + mutableScanning.value = false + } + + @SuppressLint("MissingPermission") + fun bondedDevices(): List { + if (!hasConnectPermission()) return emptyList() + return adapter?.bondedDevices?.map { device -> + AirPodsDevice( + name = runCatching { device.name }.getOrNull()?.takeIf { it.isNotBlank() } ?: "Paired device", + address = device.address, bonded = true + ) + }.orEmpty() + } + + private fun hasConnectPermission() = android.os.Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + private fun hasScanPermission() = android.os.Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED + + companion object { + private const val APPLE_COMPANY_ID = 0x004C + const val ERROR_PERMISSION = -100 + const val ERROR_UNSUPPORTED = -101 + const val ERROR_DISABLED = -102 + const val ERROR_SCANNER = -103 + const val ERROR_START = -104 + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsCommand.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsCommand.kt new file mode 100644 index 000000000..9e1538947 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsCommand.kt @@ -0,0 +1,21 @@ +package me.kavishdevar.librepods.wear.core + +import me.kavishdevar.librepods.bluetooth.AACPManager + +/** Commands accepted by the autonomous Wear controller. */ +sealed interface AirPodsCommand { + data object Connect : AirPodsCommand + data object Disconnect : AirPodsCommand + data class SetListeningMode(val mode: ListeningMode) : AirPodsCommand + data class SetEarDetection(val enabled: Boolean) : AirPodsCommand + data class SetConversationalAwareness(val enabled: Boolean) : AirPodsCommand + data class SetControlBoolean( + val identifier: AACPManager.Companion.ControlCommandIdentifiers, + val enabled: Boolean, + ) : AirPodsCommand + data class SetControlByte( + val identifier: AACPManager.Companion.ControlCommandIdentifiers, + val value: Int, + ) : AirPodsCommand + data object RefreshState : AirPodsCommand +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsController.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsController.kt new file mode 100644 index 000000000..952266c1f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsController.kt @@ -0,0 +1,208 @@ +package me.kavishdevar.librepods.wear.core + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.BLEManager +import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.data.CustomEq +import me.kavishdevar.librepods.wear.bluetooth.AirPodsProtocolDiagnostics +import me.kavishdevar.librepods.wear.bluetooth.WearBluetoothConnection + +/** Wear-facing controller for the autonomous AirPods stack. */ +class AirPodsController(private val context: Context, private val transport: WearBluetoothConnection) { + private val tag = "AirPodsController" + private val stateStore = AirPodsStateStore() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val prefs = context.getSharedPreferences("librepods_wear", Context.MODE_PRIVATE) + val state: StateFlow = stateStore.state + private var aacp: AACPManager? = null + private var ble: BLEManager? = null + private var connectedDevice: BluetoothDevice? = null + private var aacpReaderJob: Job? = null + private var readyWatchJob: Job? = null + private var reconnectJob: Job? = null + private var manualDisconnect = false + + private val bleListener = object : BLEManager.AirPodsStatusListener { + override fun onDeviceStatusChanged(device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?) = applyBleStatus(device) + override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) = applyBleStatus(device) + override fun onLidStateChanged(lidOpen: Boolean) { stateStore.update { it.copy(caseLidOpen = lidOpen) } } + override fun onEarStateChanged(device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) = applyBleStatus(device) + override fun onBatteryChanged(device: BLEManager.AirPodsStatus) = applyBleStatus(device) + override fun onDeviceDisappeared() { Log.d(tag, "AirPods BLE advertisement disappeared") } + } + + private val aacpCallback = object : AACPManager.PacketCallback { + override fun onBatteryInfoReceived(batteryInfo: ByteArray) { + recordPacket(batteryInfo) + val parsed = AirPodsProtocolDiagnostics.parseBattery(batteryInfo) ?: return + val left = parsed.firstOrNull { it.type == AirPodsProtocolDiagnostics.Component.LEFT } + val right = parsed.firstOrNull { it.type == AirPodsProtocolDiagnostics.Component.RIGHT } + val case = parsed.firstOrNull { it.type == AirPodsProtocolDiagnostics.Component.CASE } + stateStore.update { it.copy(leftBattery = left?.level ?: it.leftBattery, rightBattery = right?.level ?: it.rightBattery, caseBattery = case?.level ?: it.caseBattery, leftCharging = left?.charging ?: it.leftCharging, rightCharging = right?.charging ?: it.rightCharging, caseCharging = case?.charging ?: it.caseCharging, protocolStage = "READY", connected = true, connecting = false) } + } + override fun onEarDetectionReceived(earDetection: ByteArray) { recordPacket(earDetection); AirPodsProtocolDiagnostics.parseEarDetection(earDetection)?.let { (left, right) -> onEarDetection(left, right) } } + override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { recordPacket(conversationAwareness) } + override fun onControlCommandReceived(controlCommand: ByteArray) { + recordPacket(controlCommand) + runCatching { AACPManager.ControlCommand.fromByteArray(controlCommand) }.onSuccess { command -> + val identifier = AACPManager.Companion.ControlCommandIdentifiers.fromByte(command.identifier) + val first = command.value.firstOrNull()?.toInt()?.and(0xFF) + if (identifier != null && first != null) { + stateStore.update { it.copy(controlValues = it.controlValues + (identifier to first)) } + } + when (identifier) { + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE -> when (command.value.firstOrNull()?.toInt()?.and(0xFF)) { 1 -> onListeningModeChanged(ListeningMode.OFF); 2 -> onListeningModeChanged(ListeningMode.ANC); 3 -> onListeningModeChanged(ListeningMode.TRANSPARENCY); else -> Unit } + AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG -> stateStore.update { it.copy(earDetectionEnabled = command.value.firstOrNull()?.toInt()?.and(0xFF) == 1) } + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG -> stateStore.update { it.copy(conversationalAwarenessEnabled = command.value.firstOrNull()?.toInt()?.and(0xFF) == 1) } + else -> Unit + } + } + } + override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) { stateStore.update { it.copy(deviceName = deviceInformation.name.ifBlank { it.deviceName }, modelNumber = deviceInformation.modelNumber.ifBlank { null }, firmwareVersion = deviceInformation.version1.ifBlank { null }, serialNumber = deviceInformation.serialNumber.ifBlank { null }, protocolStage = "READY", connected = true, connecting = false) } } + override fun onHeadTrackingReceived(headTracking: ByteArray) { recordPacket(headTracking) } + override fun onUnknownPacketReceived(packet: ByteArray) { recordPacket(packet) } + override fun onProximityKeysReceived(proximityKeys: ByteArray) { recordPacket(proximityKeys) } + override fun onStemPressReceived(stemPress: ByteArray) { recordPacket(stemPress) } + override fun onAudioSourceReceived(audioSource: ByteArray) { recordPacket(audioSource) } + override fun onOwnershipChangeReceived(owns: Boolean) { Log.d(tag, "AACP ownership=$owns") } + override fun onConnectedDevicesReceived(connectedDevices: List) { Log.d(tag, "AACP connected devices=${connectedDevices.size}") } + override fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) { Log.d(tag, "AACP ownership revoke requested by $sender") } + override fun onShowNearbyUI(sender: String) { Log.d(tag, "AACP nearby UI requested by $sender") } + override fun onHeadphoneAccommodationReceived(eqData: FloatArray) { Log.d(tag, "AACP EQ frame received: ${eqData.size} values") } + override fun onCustomEqReceived(customEq: CustomEq) { Log.d(tag, "AACP custom EQ received") } + override fun onCapabilitiesReceived(capabilities: List) { Log.d(tag, "AACP capabilities=${capabilities.size}") } + } + + fun initialize(aacpManager: AACPManager, bleManager: BLEManager) { aacp = aacpManager; ble = bleManager; aacpManager.bindTransport(transport); aacpManager.setPacketCallback(aacpCallback); bleManager.setAirPodsStatusListener(bleListener); runCatching { bleManager.startScanning() }.onFailure { Log.w(tag, "BLE status scanner could not start", it) } } + + @SuppressLint("MissingPermission") + fun connectToDevice(address: String, name: String = "AirPods"): Boolean { + manualDisconnect = false; reconnectJob?.cancel(); markConnecting() + return try { + val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return fail("Bluetooth is unavailable") + if (!adapter.isEnabled) return fail("Bluetooth is disabled") + val device = adapter.getRemoteDevice(address); connectedDevice = device + prefs.edit().putString("selected_address", address).putString("selected_name", name).apply() + stateStore.update { it.copy(deviceName = name, address = address, connecting = true, connected = false, protocolStage = "CONNECTING", lastError = null) } + scope.launch { connectTransport(device) }; true + } catch (e: SecurityException) { fail("Bluetooth permission is required", e) } catch (e: IllegalArgumentException) { fail("Invalid Bluetooth device", e) } + } + + @SuppressLint("MissingPermission") + fun connectToBondedAirPods(): Boolean { + val saved = prefs.getString("selected_address", null) + if (saved != null) return connectToDevice(saved, prefs.getString("selected_name", "AirPods") ?: "AirPods") + val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter + val device = adapter?.bondedDevices?.firstOrNull { it.name.orEmpty().contains("AirPods", true) || it.name.orEmpty().contains("Pods", true) } ?: return fail("No paired AirPods found") + return connectToDevice(device.address, device.name ?: "AirPods") + } + + @SuppressLint("MissingPermission") + private suspend fun connectTransport(device: BluetoothDevice) { + try { + stateStore.update { it.copy(protocolStage = "L2CAP") }; transport.connectAacp(device) + val manager = aacp ?: error("AACP manager is not initialized") + startAacpReader(manager); check(manager.startSession()) { "AACP handshake could not be sent" } + stateStore.update { it.copy(protocolStage = "HANDSHAKE_SENT", connecting = true, connected = false) } + readyWatchJob?.cancel(); readyWatchJob = scope.launch { + repeat(50) { delay(100); if (manager.sessionState == AACPManager.SessionState.READY) { stateStore.update { it.copy(protocolStage = "READY", connecting = false, connected = true, lastError = null) }; refreshState(); return@launch } } + if (manager.sessionState != AACPManager.SessionState.READY) onError("AACP handshake timeout (${manager.sessionState})") + } + } catch (e: Throwable) { onError("AACP connection failed: ${e.message ?: e.javaClass.simpleName}", e); runCatching { transport.close() } } + } + + private fun startAacpReader(manager: AACPManager) { + aacpReaderJob?.cancel(); aacpReaderJob = scope.launch { + try { + val input = transport.aacpInput; val buffer = ByteArray(4096) + while (true) { val count = input.read(buffer); if (count <= 0) break; val packet = buffer.copyOf(count); recordPacket(packet); manager.receivePacket(packet) } + if (!manualDisconnect && connectedDevice != null) scheduleReconnect("AACP socket closed by device") + } catch (e: Throwable) { if (!manualDisconnect && connectedDevice != null) scheduleReconnect("AACP reader stopped: ${e.message ?: e.javaClass.simpleName}") } + } + } + + private fun scheduleReconnect(reason: String) { + if (manualDisconnect || connectedDevice == null || reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + onError(reason); val device = connectedDevice ?: return@launch + for (attempt in 1..3) { if (manualDisconnect) return@launch; delay(attempt * 1000L); Log.i(tag, "AACP reconnect attempt $attempt/3"); runCatching { transport.close() }; runCatching { aacp?.unbindTransport(); aacp?.bindTransport(transport) }; stateStore.update { it.copy(connecting = true, connected = false, protocolStage = "RECONNECT_$attempt", lastError = null) }; connectTransport(device); if (aacp?.sessionState == AACPManager.SessionState.READY) return@launch } + onError("AACP reconnect failed after 3 attempts") + } + } + + private fun recordPacket(packet: ByteArray?) { if (packet == null) return; val frame = AirPodsProtocolDiagnostics.decode(packet); stateStore.update { it.copy(lastPacketOpcode = AirPodsProtocolDiagnostics.opcodeName(frame?.opcode), lastPacketHex = AirPodsProtocolDiagnostics.hex(packet)) } } + private fun applyBleStatus(device: BLEManager.AirPodsStatus) { stateStore.update { it.copy(deviceName = if (device.model != "Unknown") device.model else it.deviceName, address = device.address, leftBattery = device.leftBattery, rightBattery = device.rightBattery, caseBattery = device.caseBattery, leftCharging = device.isLeftCharging, rightCharging = device.isRightCharging, caseCharging = device.isCaseCharging, caseLidOpen = device.lidOpen, leftInEar = device.isLeftInEar, rightInEar = device.isRightInEar) } } + fun markConnecting() { stateStore.update { it.copy(connecting = true, lastError = null, protocolStage = "CONNECTING") } } + fun onBattery(left: Int?, right: Int?, caseBattery: Int?) { stateStore.update { it.copy(leftBattery = left, rightBattery = right, caseBattery = caseBattery) } } + fun onEarDetection(leftInEar: Boolean, rightInEar: Boolean) { stateStore.update { it.copy(leftInEar = leftInEar, rightInEar = rightInEar) } } + fun onListeningModeChanged(mode: ListeningMode) { stateStore.update { it.copy(listeningMode = mode) } } + + /** Execute a typed controller command; used by UI and service entry points. */ + fun submit(command: AirPodsCommand): Boolean = when (command) { + AirPodsCommand.Connect -> connectToBondedAirPods() + AirPodsCommand.Disconnect -> { disconnect(); true } + AirPodsCommand.RefreshState -> refreshState() + is AirPodsCommand.SetListeningMode -> setListeningMode(command.mode) + is AirPodsCommand.SetEarDetection -> setEarDetection(command.enabled) + is AirPodsCommand.SetConversationalAwareness -> setConversationalAwareness(command.enabled) + is AirPodsCommand.SetControlBoolean -> setControlBoolean(command.identifier, command.enabled) + is AirPodsCommand.SetControlByte -> setControlByte(command.identifier, command.value) + } + + /** + * Ask the AirPods to re-send their current state. + * + * The notification request is the only inherited packet that makes the + * device replay battery, ear detection and control command values, so it + * doubles as a state refresh once the session is READY. + */ + fun refreshState(): Boolean { + val manager = aacp ?: return false + if (manager.sessionState != AACPManager.SessionState.READY) return false + return manager.sendNotificationRequest() + } + + fun setListeningMode(mode: ListeningMode): Boolean { + val value = when (mode) { ListeningMode.OFF -> 1; ListeningMode.ANC -> 2; ListeningMode.TRANSPARENCY -> 3 }.toByte() + val sent = aacp?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, byteArrayOf(value, 0, 0, 0)) == true + if (sent) onListeningModeChanged(mode); return sent + } + fun setConversationalAwareness(enabled: Boolean): Boolean { val value = if (enabled) 1 else 2; val sent = aacp?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, byteArrayOf(value.toByte(), 0, 0, 0)) == true; if (sent) stateStore.update { it.copy(conversationalAwarenessEnabled = enabled) }; return sent } + fun setEarDetection(enabled: Boolean): Boolean { val value = if (enabled) 1 else 2; val sent = aacp?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value, byteArrayOf(value.toByte(), 0, 0, 0)) == true; if (sent) stateStore.update { it.copy(earDetectionEnabled = enabled) }; return sent } + + /** Boolean control commands are encoded as `0x01` enabled / `0x02` disabled. */ + fun setControlBoolean(identifier: AACPManager.Companion.ControlCommandIdentifiers, enabled: Boolean): Boolean = + setControlByte(identifier, if (enabled) 0x01 else 0x02) + + fun setControlByte(identifier: AACPManager.Companion.ControlCommandIdentifiers, value: Int): Boolean { + val sent = aacp?.sendControlCommand(identifier.value, byteArrayOf(value.toByte(), 0, 0, 0)) == true + if (sent) { + stateStore.update { it.copy(controlValues = it.controlValues + (identifier to value)) } + when (identifier) { + AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG -> + stateStore.update { it.copy(earDetectionEnabled = value == 0x01) } + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG -> + stateStore.update { it.copy(conversationalAwarenessEnabled = value == 0x01) } + else -> Unit + } + } + return sent + } + fun onError(message: String, cause: Throwable? = null) { Log.e(tag, message, cause); stateStore.update { it.copy(connecting = false, connected = false, protocolStage = "FAILED", lastError = message) } } + private fun fail(message: String, cause: Throwable? = null): Boolean { onError(message, cause); return false } + fun disconnect() { manualDisconnect = true; connectedDevice = null; reconnectJob?.cancel(); reconnectJob = null; readyWatchJob?.cancel(); readyWatchJob = null; aacpReaderJob?.cancel(); aacpReaderJob = null; runCatching { transport.close() }; aacp?.unbindTransport(); stateStore.update { it.copy(connected = false, connecting = false, protocolStage = "IDLE") } } + fun shutdown() { disconnect(); runCatching { ble?.stopScanning() }; aacp?.unbindTransport(); scope.cancel(); aacp = null; ble = null; stateStore.reset() } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsDevice.kt new file mode 100644 index 000000000..6f192aa09 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsDevice.kt @@ -0,0 +1,17 @@ +package me.kavishdevar.librepods.wear.core + +/** + * User-selectable Bluetooth device identity exposed by the Wear UI. + * + * Discovery metadata is kept here so the UI can diagnose AirPods advertisements + * without coupling the UI to Android Bluetooth APIs. + */ +data class AirPodsDevice( + val name: String, + val address: String, + val rssi: Int? = null, + val bonded: Boolean = false, + val appleManufacturer: Boolean = false, + val serviceUuids: List = emptyList(), + val seenAtMillis: Long = System.currentTimeMillis(), +) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsEvent.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsEvent.kt new file mode 100644 index 000000000..0ff2ab057 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsEvent.kt @@ -0,0 +1,11 @@ +package me.kavishdevar.librepods.wear.core + +/** Events emitted by the autonomous AirPods protocol pipeline. */ +sealed interface AirPodsEvent { + data class Connected(val address: String, val name: String?) : AirPodsEvent + data object Disconnected : AirPodsEvent + data class Battery(val left: Int?, val right: Int?, val caseBattery: Int?) : AirPodsEvent + data class EarDetection(val leftInEar: Boolean, val rightInEar: Boolean) : AirPodsEvent + data class ListeningMode(val mode: me.kavishdevar.librepods.wear.core.ListeningMode) : AirPodsEvent + data class Error(val message: String, val cause: Throwable? = null) : AirPodsEvent +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsState.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsState.kt new file mode 100644 index 000000000..c7ab5ae0e --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/core/AirPodsState.kt @@ -0,0 +1,55 @@ +package me.kavishdevar.librepods.wear.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import me.kavishdevar.librepods.bluetooth.AACPManager + +/** Immutable state exposed by the autonomous Wear AirPods controller. */ +data class AirPodsState( + val deviceName: String = "AirPods", + val address: String? = null, + val modelNumber: String? = null, + val firmwareVersion: String? = null, + val serialNumber: String? = null, + val connected: Boolean = false, + val connecting: Boolean = false, + val leftBattery: Int? = null, + val rightBattery: Int? = null, + val caseBattery: Int? = null, + val leftCharging: Boolean = false, + val rightCharging: Boolean = false, + val caseCharging: Boolean = false, + val caseLidOpen: Boolean? = null, + val listeningMode: ListeningMode = ListeningMode.OFF, + val leftInEar: Boolean? = null, + val rightInEar: Boolean? = null, + val earDetectionEnabled: Boolean? = null, + val conversationalAwarenessEnabled: Boolean? = null, + /** First byte of every control command value reported by the AirPods, keyed by identifier. */ + val controlValues: Map = emptyMap(), + val protocolStage: String = "IDLE", + val lastPacketOpcode: String? = null, + val lastPacketHex: String? = null, + val lastError: String? = null, +) + +enum class ListeningMode { + ANC, + TRANSPARENCY, + OFF, +} + +/** Small state holder; protocol adapters can update it without knowing about Compose. */ +class AirPodsStateStore(initial: AirPodsState = AirPodsState()) { + private val mutableState = MutableStateFlow(initial) + val state: StateFlow = mutableState.asStateFlow() + + fun update(transform: (AirPodsState) -> AirPodsState) { + mutableState.value = transform(mutableState.value) + } + + fun reset() { + mutableState.value = AirPodsState() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/service/LibrePodsWearService.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/service/LibrePodsWearService.kt new file mode 100644 index 000000000..0ed134ad9 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/service/LibrePodsWearService.kt @@ -0,0 +1,157 @@ +package me.kavishdevar.librepods.wear.service + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Binder +import android.os.IBinder +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.MainActivity +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.BLEManager +import me.kavishdevar.librepods.wear.bluetooth.AirPodsConnectionSession +import me.kavishdevar.librepods.wear.bluetooth.WearBluetoothConnection +import me.kavishdevar.librepods.wear.bluetooth.WearBluetoothScanner +import me.kavishdevar.librepods.wear.core.AirPodsController +import me.kavishdevar.librepods.wear.core.AirPodsState + +/** + * Owner of the autonomous AirPods stack on the watch. + * + * The service holds the only [AirPodsController] instance so the protocol + * session survives UI recreation, and keeps the connection alive in the + * background as a connected-device foreground service. + */ +class LibrePodsWearService : Service() { + inner class LocalBinder : Binder() { + val service: LibrePodsWearService get() = this@LibrePodsWearService + } + + lateinit var controller: AirPodsController + private set + lateinit var scanner: WearBluetoothScanner + private set + + private lateinit var transport: WearBluetoothConnection + private lateinit var session: AirPodsConnectionSession + private val binder = LocalBinder() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var foregroundStarted = false + + override fun onCreate() { + super.onCreate() + + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + ?: error("Bluetooth adapter is unavailable") + + session = AirPodsConnectionSession(adapter) + transport = WearBluetoothConnection(this) + transport.attachSession(session) + + scanner = WearBluetoothScanner(this) + controller = AirPodsController(this, transport) + controller.initialize( + aacpManager = AACPManager(), + bleManager = BLEManager(this), + ) + + createNotificationChannel() + scope.launch { controller.state.collectLatest(::onStateChanged) } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForegroundIfPossible(controller.state.value) + when (intent?.action) { + ACTION_CONNECT -> { + val address = intent.getStringExtra(EXTRA_ADDRESS) + val name = intent.getStringExtra(EXTRA_NAME) ?: "AirPods" + if (address != null) controller.connectToDevice(address, name) else controller.connectToBondedAirPods() + } + ACTION_DISCONNECT -> controller.disconnect() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onDestroy() { + scope.cancel() + controller.shutdown() + scanner.stopScan() + transport.close() + super.onDestroy() + } + + private fun onStateChanged(state: AirPodsState) { + if (!foregroundStarted) return + getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification(state)) + } + + private fun startForegroundIfPossible(state: AirPodsState) { + if (foregroundStarted) return + if (!hasConnectPermission()) return + runCatching { startForeground(NOTIFICATION_ID, buildNotification(state)) } + .onSuccess { foregroundStarted = true } + } + + private fun buildNotification(state: AirPodsState): Notification { + val content = when { + state.connecting -> "Connecting…" + state.connected -> listOfNotNull( + state.leftBattery?.takeIf { it in 0..100 }?.let { "L $it%" }, + state.rightBattery?.takeIf { it in 0..100 }?.let { "R $it%" }, + state.caseBattery?.takeIf { it in 0..100 }?.let { "C $it%" }, + ).joinToString(" · ").ifEmpty { "Connected" } + else -> state.lastError ?: "Disconnected" + } + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentTitle(state.deviceName) + .setContentText(content) + .setContentIntent(contentIntent) + .setOngoing(true) + .build() + } + + private fun createNotificationChannel() { + val channel = NotificationChannel(CHANNEL_ID, "AirPods connection", NotificationManager.IMPORTANCE_LOW) + channel.setShowBadge(false) + getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + + private fun hasConnectPermission(): Boolean = + android.os.Build.VERSION.SDK_INT < 31 || + ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + + companion object { + private const val CHANNEL_ID = "librepods_wear_connection" + private const val NOTIFICATION_ID = 1001 + const val ACTION_CONNECT = "me.kavishdevar.librepods.wear.CONNECT" + const val ACTION_DISCONNECT = "me.kavishdevar.librepods.wear.DISCONNECT" + const val EXTRA_ADDRESS = "address" + const val EXTRA_NAME = "name" + + fun start(context: Context) { + ContextCompat.startForegroundService(context, Intent(context, LibrePodsWearService::class.java)) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsHomeScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsHomeScreen.kt new file mode 100644 index 000000000..cb9a8821c --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsHomeScreen.kt @@ -0,0 +1,232 @@ +package me.kavishdevar.librepods.wear.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers +import me.kavishdevar.librepods.wear.bluetooth.WearBluetoothScanner +import me.kavishdevar.librepods.wear.core.AirPodsController +import me.kavishdevar.librepods.wear.core.AirPodsDevice +import me.kavishdevar.librepods.wear.core.AirPodsState + +/** Wear home screen: status, battery, AirPods controls and paired device list. */ +@Composable +fun AirPodsHomeScreen( + controller: AirPodsController, + scanner: WearBluetoothScanner, + onOpenSystemBluetooth: () -> Unit, + onRefresh: () -> Unit, +) { + val state by controller.state.collectAsState() + val devices by scanner.devices.collectAsState() + val listState = rememberScalingLazyListState() + + MaterialTheme { + AppScaffold { + ScreenScaffold(scrollState = listState) { contentPadding -> + ScalingLazyColumn( + state = listState, + contentPadding = contentPadding, + modifier = Modifier.fillMaxWidth(), + ) { + item { ListHeader { Text(state.deviceName) } } + item { StatusText(state) } + + if (state.connected || state.leftBattery != null || state.rightBattery != null || state.caseBattery != null) { + item { BatteryRow(state) } + } + + if (state.connected) { + item { + ListeningModeRow(selected = state.listeningMode) { mode -> + if (!controller.setListeningMode(mode)) controller.onError("Failed to set listening mode") + } + } + item { + ToggleRow("Ear detection", state.earDetectionEnabled == true) { enabled -> + if (!controller.setEarDetection(enabled)) controller.onError("Failed to set ear detection") + } + } + item { + ToggleRow("Conversation", state.conversationalAwarenessEnabled == true) { enabled -> + if (!controller.setConversationalAwareness(enabled)) controller.onError("Failed to set conversation awareness") + } + } + item { EarStatusText(state) } + + item { ListHeader { Text("Settings") } } + state.adaptiveStrengthPercent()?.let { percent -> + item { + StepperRow("Adaptive strength", percent) { value -> + if (!controller.setControlByte(ControlCommandIdentifiers.AUTO_ANC_STRENGTH, 100 - value)) { + controller.onError("Failed to set adaptive strength") + } + } + } + } + settingItems(state, controller) + infoItems(state) + + item { + Button(onClick = { controller.refreshState() }, modifier = Modifier.fillMaxWidth()) { + Text("Refresh state") + } + } + item { + Button(onClick = { controller.disconnect() }, modifier = Modifier.fillMaxWidth()) { + Text("Disconnect") + } + } + } else { + item { ListHeader { Text("Paired devices") } } + if (devices.isEmpty()) { + item { Text("No paired devices", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) } + } + deviceItems(devices, controller) + item { + Button(onClick = onOpenSystemBluetooth, modifier = Modifier.fillMaxWidth()) { Text("Pair in settings") } + } + item { + Button(onClick = onRefresh, modifier = Modifier.fillMaxWidth()) { Text("Refresh") } + } + } + } + } + } + } +} + +/** Model, firmware and serial reported by the AirPods device information packet. */ +private fun ScalingLazyListScope.infoItems(state: AirPodsState) { + val rows = listOfNotNull( + state.modelNumber?.let { "Model" to it }, + state.firmwareVersion?.let { "Firmware" to it }, + state.serialNumber?.let { "Serial" to it }, + state.address?.let { "Address" to it }, + ) + if (rows.isEmpty()) return + item { ListHeader { Text("About") } } + rows.forEach { (label, value) -> + item(key = "info_$label") { + Column(Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelSmall) + Text(value, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** Boolean control command toggles; only settings already reported by the AirPods are shown. */ +private fun ScalingLazyListScope.settingItems( + state: AirPodsState, + controller: AirPodsController, +) { + BooleanSettings.filter { (_, identifier) -> state.controlValues.containsKey(identifier) } + .forEach { (label, identifier) -> + item(key = identifier.name) { + ToggleRow(label, state.isControlEnabled(identifier)) { enabled -> + if (!controller.setControlBoolean(identifier, enabled)) { + controller.onError("Failed to set $label") + } + } + } + } +} + +private fun ScalingLazyListScope.deviceItems( + devices: List, + controller: AirPodsController, +) { + devices.forEach { device -> + item(key = device.address) { + Button( + onClick = { controller.connectToDevice(device.address, device.name) }, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text(device.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + if (device.bonded) "Paired · tap to connect" else "Nearby", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } +} + +@Composable +private fun StatusText(state: AirPodsState) { + val status = when { + state.connecting -> "Connecting… (${state.protocolStage})" + state.connected -> "Connected" + state.lastError != null -> state.lastError + else -> "Not connected" + } + Text( + status.orEmpty(), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun EarStatusText(state: AirPodsState) { + if (state.leftInEar == null && state.rightInEar == null) return + val text = "In ear: ${state.leftInEar.asEarLabel()} / ${state.rightInEar.asEarLabel()}" + Text( + text, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun Boolean?.asEarLabel(): String = when (this) { + true -> "yes" + false -> "no" + null -> "—" +} + +@Composable +private fun BatteryRow(state: AirPodsState) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + BatteryCell("L", state.leftBattery, state.leftCharging) + BatteryCell("R", state.rightBattery, state.rightCharging) + BatteryCell("C", state.caseBattery, state.caseCharging) + } +} + +@Composable +private fun BatteryCell(label: String, level: Int?, charging: Boolean) { + val value = if (level != null && level in 0..100) "$level%" else "--" + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelSmall) + Text(if (charging && value != "--") "$value +" else value, style = MaterialTheme.typography.bodySmall) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsSettings.kt new file mode 100644 index 000000000..4657c9811 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/AirPodsSettings.kt @@ -0,0 +1,62 @@ +package me.kavishdevar.librepods.wear.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers +import me.kavishdevar.librepods.wear.core.AirPodsState + +/** + * Boolean AirPods settings exposed on the watch. + * + * All of them use the same control command encoding: `0x01` enabled, + * `0x02` disabled, and the AirPods report the current value back with the + * same identifier. + */ +val BooleanSettings: List> = listOf( + "Adaptive volume" to ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, + "Volume swipe" to ControlCommandIdentifiers.VOLUME_SWIPE_MODE, + "ANC with one bud" to ControlCommandIdentifiers.ONE_BUD_ANC_MODE, + "Allow Off mode" to ControlCommandIdentifiers.ALLOW_OFF_OPTION, + "Auto connect" to ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, + "Sleep detection" to ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG, + "Hearing protection" to ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, + "Dynamic end of charge" to ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE, +) + +/** A control command value is reported as enabled when its first byte is `0x01`. */ +fun AirPodsState.isControlEnabled(identifier: ControlCommandIdentifiers): Boolean = + controlValues[identifier] == 0x01 + +/** Adaptive strength is sent inverted: the slider percentage is `100 - rawValue`. */ +fun AirPodsState.adaptiveStrengthPercent(): Int? = + controlValues[ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.let { 100 - it.coerceIn(0, 100) } + +@Composable +fun StepperRow( + label: String, + value: Int, + modifier: Modifier = Modifier, + step: Int = 10, + onValueChange: (Int) -> Unit, +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelSmall) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalButton(onClick = { onValueChange((value - step).coerceIn(0, 100)) }) { Text("-") } + Text("$value%", style = MaterialTheme.typography.bodyMedium) + FilledTonalButton(onClick = { onValueChange((value + step).coerceIn(0, 100)) }) { Text("+") } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/WatchScreens.kt b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/WatchScreens.kt new file mode 100644 index 000000000..0444f9ac1 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/wear/ui/WatchScreens.kt @@ -0,0 +1,97 @@ +package me.kavishdevar.librepods.wear.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.OutlinedButton +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SwitchButton +import androidx.wear.compose.material3.Text +import me.kavishdevar.librepods.wear.core.ListeningMode + +/** Reusable compact Wear controls bound to the AirPods controller state. */ +@Composable +fun ListeningModeRow( + selected: ListeningMode, + modifier: Modifier = Modifier, + onSelected: (ListeningMode) -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + ) { + ListeningMode.entries.forEach { mode -> + if (mode == selected) { + FilledTonalButton( + onClick = { onSelected(mode) }, + modifier = Modifier.weight(1f), + ) { Text(mode.shortLabel, textAlign = TextAlign.Center) } + } else { + OutlinedButton( + onClick = { onSelected(mode) }, + modifier = Modifier.weight(1f), + ) { Text(mode.shortLabel, textAlign = TextAlign.Center) } + } + } + } +} + +@Composable +fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + SwitchButton( + checked = checked, + onCheckedChange = onCheckedChange, + label = { Text(label) }, + modifier = modifier.fillMaxWidth(), + ) +} + +/** Shown while the UI is waiting for the AirPods service or permissions. */ +@Composable +fun StartupScreen(message: String, onOpenSettings: () -> Unit) { + MaterialTheme { + AppScaffold { + ScreenScaffold { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Button( + onClick = onOpenSettings, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text("Settings") } + } + } + } + } +} + +private val ListeningMode.shortLabel: String + get() = when (this) { + ListeningMode.ANC -> "ANC" + ListeningMode.TRANSPARENCY -> "Trns" + ListeningMode.OFF -> "Off" + } diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2.png b/android/app/src/main/res-apple/drawable/airpods_pro_2.png deleted file mode 100644 index 681ee750a..000000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2.png and /dev/null differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png deleted file mode 100644 index 8bea6a255..000000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2_buds.png and /dev/null differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png deleted file mode 100644 index be694b048..000000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png and /dev/null differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png deleted file mode 100644 index 88e13948e..000000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2_left.png and /dev/null differ diff --git a/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png b/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png deleted file mode 100644 index 76495bee9..000000000 Binary files a/android/app/src/main/res-apple/drawable/airpods_pro_2_right.png and /dev/null differ diff --git a/android/app/src/main/res-apple/font/sf_pro.otf b/android/app/src/main/res-apple/font/sf_pro.otf deleted file mode 100644 index dd28280fa..000000000 Binary files a/android/app/src/main/res-apple/font/sf_pro.otf and /dev/null differ diff --git a/android/app/src/main/res/drawable/ic_undo.xml b/android/app/src/main/res/drawable/ic_undo.xml deleted file mode 100644 index a3f745b76..000000000 --- a/android/app/src/main/res/drawable/ic_undo.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/settings_voice.xml b/android/app/src/main/res/drawable/settings_voice.xml deleted file mode 100644 index 583157084..000000000 --- a/android/app/src/main/res/drawable/settings_voice.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml deleted file mode 100644 index df54f5d45..000000000 --- a/android/app/src/main/res/layout/battery_widget.xml +++ /dev/null @@ -1,268 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml deleted file mode 100644 index c804737b5..000000000 --- a/android/app/src/main/res/layout/island_window.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/noise_control_widget.xml b/android/app/src/main/res/layout/noise_control_widget.xml deleted file mode 100644 index baa2f4bdd..000000000 --- a/android/app/src/main/res/layout/noise_control_widget.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/notification.xml b/android/app/src/main/res/layout/notification.xml deleted file mode 100644 index fba864b57..000000000 --- a/android/app/src/main/res/layout/notification.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/android/app/src/main/res/layout/notification_expanded.xml b/android/app/src/main/res/layout/notification_expanded.xml deleted file mode 100644 index 9b6213c9f..000000000 --- a/android/app/src/main/res/layout/notification_expanded.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/popup_window.xml b/android/app/src/main/res/layout/popup_window.xml deleted file mode 100644 index f00068db0..000000000 --- a/android/app/src/main/res/layout/popup_window.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 0f3f84e94..f70ced5f5 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,38 +1,5 @@ - - - - - - - - - - - + + +