diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..72478e6 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,9 @@ +- v1.0.2 + • In-App Updates: Added OTA updates via GitHub Releases. + • Offline Wake Word: Added Snowboy hotword detection (hey_rio, alexa, snowboy) with gain and sensitivity controls. + • Custom Model Training: Added in-app voice sample recording and hotword training via Seasalt API. + • Multi-Assistant Support: Added selection support for any installed assistant app (Google, ChatGPT, Claude, Perplexity). + • Onboarding Wizard: Added setup screen for Mic, Overlay, Auto-Start, local ADB, and Notification permissions. + • Background Launch Fix: Fixed voice assistant launch issues from background service. + • UI & UX Polish: Added vertical scrolling to dialogs, debounce on record button, and activation chime sound. + • CI / Build: Added beta build workflow and bumped version to 1.0.2. \ No newline at end of file diff --git a/README.md b/README.md index 576478a..df812c0 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,108 @@ # Assistant -Assistant (`com.kangrio.byd.assistant`) is an Android utility designed for BYD vehicle infotainment units and custom Android head units. +**Assistant** (`com.kangrio.byd.assistant`) is an Android utility and voice interaction bridge tailored for BYD vehicle infotainment units (DiLink) and custom Android head units / AOSP devices. -It configures Google Assistant (`com.google.android.googlequicksearchbox`) as the default voice interaction service on the device and provides a direct launcher shortcut for voice commands. +Launcher shortcuts directly to your preferred AI voice assistant (Google Assistant, ChatGPT, Perplexity, Claude, etc.), provides **always-listening offline hotword / wake-word detection** ("Hey Rio", "Alexa", "Snowboy", or custom trained models), and includes a modern onboarding wizard to configure system-level voice interaction services with ease. --- -## Features - -- **One-Tap Voice Launcher**: Once set up, launching the app (`StartActivity`) checks requirements and immediately invokes Google Voice Assistant (`android.intent.action.VOICE_COMMAND`) instead of displaying the configuration screen. -- **Modular 4-Step Setup Wizard**: Jetpack Compose and Material 3 interface guides users through setup step-by-step: - 1. **Google App Check**: Verifies if Google App (`com.google.android.googlequicksearchbox`) is installed, with direct Google Play Store redirection if missing. - 2. **On-Device ADB Granting**: Uses `dadb` to request and grant `WRITE_SECURE_SETTINGS` locally over ADB without needing a computer, complete with retry logic and connection timeouts. - 3. **Voice Assistant Configuration**: Configures required system secure settings keys with one tap. - 4. **Launcher Test**: Includes a direct test button to verify intent execution. -- **Auto-Refresh Lifecycle Observer**: Automatically re-checks permission and package states whenever `MainActivity` resumes (e.g. after installing Google App or altering system settings). -- **Manual ADB Support**: Displays exact `adb` shell commands with a one-click **Copy Command** button as a fallback method. -- **Boot Persistence**: A background broadcast receiver (`BootReceiver`) re-applies voice assistant settings whenever the device reboots. +## Key Features + +- **One-Tap Voice Launcher**: + - Launching the app via home launcher icon, steering wheel button, or widget immediately invokes your selected voice assistant without opening configuration screens once set up. + - Custom launch profiles: + - **Google App / Google Assistant**: Executes `android.intent.action.VOICE_COMMAND`. + - **ChatGPT**: Direct intent target to ChatGPT's voice assistant mode (`com.openai.voice.assistant.AssistantActivity`). + - **Other AI / Voice Interaction Services**: Dispatches standard `Intent.ACTION_ASSIST`. + - Optional audible start chime ("ding") confirmation sound upon activation. + +- **Offline Hotword & Wake-Word Detection (`VoiceWakeService`)**: + - Continuous, low-latency wake-word detection powered by embedded Snowboy engine. + - Ships with bundled offline models: **Hey Rio** (`hey_rio.pmdl`), **Alexa** (`alexa.pmdl`), and **Snowboy** (`snowboy.pmdl`). + - Configurable microphone input gain and detection sensitivity sliders. + - Runs efficiently as a foreground service with a persistent notification and quick-access settings. + +- **In-App Custom Wake-Word Trainer**: + - Built-in training studio powered by Snowboy Seasalt API. + - Record voice samples directly on the head unit / device (minimum 3 samples). + - Train custom `.pmdl` wake-word models on the fly. + +- **Guided Jetpack Compose Onboarding Wizard (`PermissionOnboardingActivity`)**: + - Interactive status check and setup workflow for all required capabilities: + 1. **Microphone Access**: Grants audio capture for hotword detection. + 2. **Display Over Other Apps**: Allows launching the assistant UI seamlessly over active apps (e.g. navigation / media). + 3. **Auto-Start & Battery Management**: Native handling for BYD DiLink (`BYD_APPSTARTMANAGEMENT`) and standard Android battery optimization bypass. + 4. **Write Secure Settings**: Automatic on-device grant via local ADB socket (`dadb`) or manual ADB command copy. + 5. **Target AI Assistant Selection**: Scans installed `VoiceInteractionService` providers and allows 1-tap switching. + 6. **Notification Permission**: Enables background status indication (Android 13+). + +- **Boot Persistence**: + - `BootReceiver` captures `BOOT_COMPLETED` to re-apply `Settings.Secure` assistant keys and initialize the voice wake service on startup. --- -## Architecture and Flow +## Architecture & Flow ``` - +---------------------------+ - | User Launches App | - +-------------+-------------+ - | - v - +---------------------------+ - | StartActivity | - +-------------+-------------+ - | - Are WRITE_SECURE_SETTINGS Granted, - Voice Assistant Enabled, AND Google App Installed? - / \ - YES NO - / \ - v v - +-----------------------+ +-----------------------+ - | Trigger Voice Command | | Open MainActivity | - | (Google Assistant) | | (4-Step Setup Guide) | - +-----------------------+ +-----------------------+ + +---------------------------+ + | User Launches App | + | or Wake Word Detected | + +-------------+-------------+ + | + v + +---------------------------+ + | StartActivity | + +-------------+-------------+ + | + Is Setup Fully Completed? + (Secure Settings, Audio, Assistant App, + Auto-Start, Overlay Permissions) + / \ + YES NO + / \ + v v + +-----------------------+ +--------------------------------+ + | Trigger Voice Command | | PermissionOnboardingActivity | + | - Google Assistant | | (Setup Wizard) | + | - ChatGPT Voice | +--------------------------------+ + | - Custom Assist App | + +-----------------------+ ``` -1. **StartActivity**: Entry point registered for the launcher icon (`SplashActivity.kt`). - - Checks whether `WRITE_SECURE_SETTINGS` is granted, Google Assistant service is active, and Google App is installed. - - If fully configured, it fires `android.intent.action.VOICE_COMMAND` directed at `com.google.android.googlequicksearchbox` and exits immediately. - - If any requirement is missing, it routes the user to `MainActivity`. -2. **MainActivity**: Step-by-step configuration dashboard (`MainActivity.kt`). - - Displays modular `StepCard` UI components with completion indicators, descriptions, and active status tracking. - - Handles auto-refreshing setup states on lifecycle `ON_RESUME`. -3. **BootReceiver**: Catches `Intent.ACTION_BOOT_COMPLETED` events to ensure secure settings persist across device reboots (`receiver/BootReceiver.kt`). - --- ## Secure Settings -The application modifies the following `Settings.Secure` system keys to register Google Assistant: +To designate the default voice interaction provider system-wide, the app configures the following `Settings.Secure` keys: -| Key | Value | +| Key | Description / Example Value | | :--- | :--- | -| `assistant` | `com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService` | -| `voice_interaction_service` | `com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService` | +| `assistant` | Flattened component name (e.g., `com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService`) | +| `voice_interaction_service` | Flattened component name (e.g., `com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService`) | --- -## Requirements and Setup - -### Prerequisites -- Android 7.0 (API level 24) or higher. -- Google App (`com.google.android.googlequicksearchbox`) installed on the target device. - -### Setup Guide (In-App Wizard) - -1. **Step 1: Google App Check** - - Verify that Google App is installed. If missing, tap **Install Google App** to open Play Store. -2. **Step 2: Grant Secure Settings Permission** - - Tap **Permission: Next** to automatically grant `WRITE_SECURE_SETTINGS` via local `dadb`. - - *Fallback*: If auto-grant fails, copy the provided `adb shell pm grant` command and execute it via PC or Wireless ADB. -3. **Step 3: Configure Voice Assistant Service** - - Tap **Enable Voice Assistant** to set `GsaVoiceInteractionService` in `Settings.Secure`. -4. **Step 4: Test Voice Assistant Launcher** - - Tap **Test Voice Assistant** to execute a voice command intent. -5. **Auto-Start**: - - On head units (e.g. BYD DiLink), ensure Auto-start is allowed for Google App, Gemini (if used), and Assistant. +## Setup & Permissions ---- - -## Granting Secure Settings Permission +### Granting `WRITE_SECURE_SETTINGS` -Modifying assistant settings requires `android.permission.WRITE_SECURE_SETTINGS`. +Modifying system assistant keys requires the `android.permission.WRITE_SECURE_SETTINGS` permission. -### Method 1: In-App Automatic Grant (dadb) -1. Open the application (`MainActivity`). -2. Tap **Permission: Next** in Step 2. The app will attempt local ADB connection via `dadb` (with built-in retry mechanism and 10-second timeout) and grant the permission automatically. +#### Method 1: Automatic In-App Grant (Local ADB) +1. Open the app onboarding screen. +2. Under **Write Secure Settings**, tap **Grant via Local ADB**. +3. The app connects to the local ADB daemon via `dadb` and grants the permission automatically without needing a computer. -### Method 2: Manual ADB Command -If automatic ADB discovery fails or local ADB socket access is disabled on the head unit, execute the following command via PC or Wireless ADB: +#### Method 2: Manual ADB Command +If local ADB discovery is unavailable, execute the following command via PC or Wireless ADB: ```bash adb shell pm grant com.kangrio.byd.assistant android.permission.WRITE_SECURE_SETTINGS ``` -Then return to the app and proceed to **Step 3: Enable Voice Assistant**. - --- -## Repository Structure +## Project Structure ``` Assistant/ @@ -116,14 +111,38 @@ Assistant/ │ └── src/ │ └── main/ │ ├── AndroidManifest.xml -│ └── java/com/kangrio/byd/assistant/ -│ ├── MainActivity.kt # 4-Step setup UI & Compose dashboard -│ ├── SplashActivity.kt # Entry point (StartActivity / launcher handler) -│ ├── receiver/ -│ │ └── BootReceiver.kt # Re-applies settings after boot -│ ├── ui/theme/ # Material 3 theme styling -│ └── util/ -│ └── Utils.kt # Dadb helper, store intent, & secure settings +│ ├── assets/ +│ │ └── snowboy/ # Pretrained models (.pmdl) & resources +│ │ ├── common.res +│ │ └── models/ +│ │ ├── alexa.pmdl +│ │ ├── hey_rio.pmdl +│ │ └── snowboy.pmdl +│ ├── java/com/kangrio/byd/assistant/ +│ │ ├── App.kt # Application entry point +│ │ ├── Constant.kt # Shared constants & preference keys +│ │ ├── StartActivity.kt # Fast launcher & voice intent dispatcher +│ │ ├── activity/ +│ │ │ ├── PermissionOnboardingActivity.kt # Guided onboarding host +│ │ │ └── SettingsActivity.kt # Hotword settings & voice trainer +│ │ ├── data/ +│ │ │ └── AssistantApp.kt # Assistant metadata model +│ │ ├── receiver/ +│ │ │ └── BootReceiver.kt # Restores settings & service on boot +│ │ ├── service/ +│ │ │ ├── SnowboyDetector.kt# Audio recorder & Snowboy JNI bridge +│ │ │ └── VoiceWakeService.kt # Foreground service for wake word +│ │ ├── ui/ +│ │ │ ├── composable/ # Shared UI components & app icons +│ │ │ ├── onboarding/ # Onboarding wizard screens & permission cards +│ │ │ └── theme/ # Material 3 theme & typography +│ │ └── util/ +│ │ ├── Preferences.kt # SharedPreferences wrapper +│ │ ├── SnowboyTrainClient.kt # API client for custom model training +│ │ └── Utils.kt # System helpers, dadb runner & intent utilities +│ └── res/ +│ └── raw/ +│ └── ding.mp3 # Activation chime ├── build.gradle.kts └── settings.gradle.kts ``` @@ -133,8 +152,9 @@ Assistant/ ## Building from Source ### Requirements -- Android Studio Ladybug or JDK 11+ -- Gradle 8.x +- Android Studio +- Android SDK (API 37 compile target, `minSdk` 24) +- Supported ABIs: `armeabi-v7a`, `arm64-v8a`, `x86_64` ### Build Commands @@ -144,22 +164,31 @@ Assistant/ # Build Release APK ./gradlew assembleRelease + +# Build Beta Variant +./gradlew assembleBeta ``` -Build outputs are saved to `app/build/outputs/apk//` using the following naming convention: +Artifacts will be generated in `app/build/outputs/apk//` following the naming format: `Assistant-()--.apk` --- -## Permissions +## Permissions Overview -- `android.permission.WRITE_SECURE_SETTINGS`: Required to switch system voice interaction services. -- `android.permission.RECEIVE_BOOT_COMPLETED`: Used to restore settings on boot. -- `android.permission.INTERNET`: Used by `dadb` to communicate with the local ADB daemon. -- `android.permission.POST_NOTIFICATIONS`: For system notifications. +| Permission | Purpose | +| :--- | :--- | +| `RECORD_AUDIO` | Required for wake-word listening and voice sample recording. | +| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Keeps the hotword detector running reliably in the background. | +| `SYSTEM_ALERT_WINDOW` | Enables launching assistant interfaces over active apps. | +| `WRITE_SECURE_SETTINGS` | Configures system default assistant and voice interaction services. | +| `RECEIVE_BOOT_COMPLETED` | Automatically restarts hotword service and secures settings on device boot. | +| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Prevents Android OS from killing background services in standby. | +| `POST_NOTIFICATIONS` | Displays the foreground service notification on Android 13+. | +| `INTERNET` | Used for local ADB loopback socket communication and wake-word model training. | --- ## License -Distributed under the MIT License. +Distributed under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 101d364..c935837 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.kotlin.compose) } +val runNumber = providers.environmentVariable("GITHUB_RUN_NUMBER").orElse("0") + android { namespace = "com.kangrio.byd.assistant" compileSdk { @@ -18,8 +20,11 @@ android { applicationId = "com.kangrio.byd.assistant" minSdk = 24 targetSdk = 37 - versionCode = 2 - versionName = "1.0.1" + versionCode = 3 + versionName = "1.0.2" + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64") + } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -44,10 +49,17 @@ android { optimization { enable = true } - if (file("signing.properties").exists()) { - signingConfig = signingConfigs.getByName("release") + signingConfig = if (file("signing.properties").exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") } } + + register("beta") { + initWith(getByName("release")) + versionNameSuffix = "-beta.${runNumber.get()}" + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -56,6 +68,9 @@ android { buildFeatures { compose = true } + sourceSets["main"].apply { + jniLibs.srcDirs("src/main/jniLibs") + } } androidComponents { @@ -72,6 +87,10 @@ androidComponents { dependencies { implementation(libs.dadb) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.okhttp) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.accompanist.drawablepainter) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) diff --git a/app/release/baselineProfiles/0/Assistant-1.0(1)-release-08082026220039.dm b/app/release/baselineProfiles/0/Assistant-1.0(1)-release-08082026220039.dm deleted file mode 100644 index 1158b36..0000000 Binary files a/app/release/baselineProfiles/0/Assistant-1.0(1)-release-08082026220039.dm and /dev/null differ diff --git a/app/release/baselineProfiles/1/Assistant-1.0(1)-release-08082026220039.dm b/app/release/baselineProfiles/1/Assistant-1.0(1)-release-08082026220039.dm deleted file mode 100644 index 36750bb..0000000 Binary files a/app/release/baselineProfiles/1/Assistant-1.0(1)-release-08082026220039.dm and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 798c688..c763941 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,15 +9,19 @@ tools:ignore="ProtectedPermissions" /> + - - - - - - + + + + + + + - @@ -41,12 +42,38 @@ + + + + - + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/snowboy/common.res b/app/src/main/assets/snowboy/common.res new file mode 100644 index 0000000..fe6fa95 Binary files /dev/null and b/app/src/main/assets/snowboy/common.res differ diff --git a/app/src/main/assets/snowboy/models/alexa.pmdl b/app/src/main/assets/snowboy/models/alexa.pmdl new file mode 100644 index 0000000..f916312 Binary files /dev/null and b/app/src/main/assets/snowboy/models/alexa.pmdl differ diff --git a/app/src/main/assets/snowboy/models/hey_rio.pmdl b/app/src/main/assets/snowboy/models/hey_rio.pmdl new file mode 100644 index 0000000..4e61278 Binary files /dev/null and b/app/src/main/assets/snowboy/models/hey_rio.pmdl differ diff --git a/app/src/main/assets/snowboy/models/snowboy.pmdl b/app/src/main/assets/snowboy/models/snowboy.pmdl new file mode 100644 index 0000000..bb68185 Binary files /dev/null and b/app/src/main/assets/snowboy/models/snowboy.pmdl differ diff --git a/app/src/main/java/ai/kitt/snowboy/SnowboyDetect.java b/app/src/main/java/ai/kitt/snowboy/SnowboyDetect.java new file mode 100644 index 0000000..33405a9 --- /dev/null +++ b/app/src/main/java/ai/kitt/snowboy/SnowboyDetect.java @@ -0,0 +1,104 @@ +/* ---------------------------------------------------------------------------- + * This file was automatically generated by SWIG (http://www.swig.org). + * Version 4.0.2 + * + * Do not make changes to this file unless you know what you are doing--modify + * the SWIG interface file instead. + * ----------------------------------------------------------------------------- */ + +package ai.kitt.snowboy; + +public class SnowboyDetect { + private transient long swigCPtr; + protected transient boolean swigCMemOwn; + + protected SnowboyDetect(long cPtr, boolean cMemoryOwn) { + swigCMemOwn = cMemoryOwn; + swigCPtr = cPtr; + } + + protected static long getCPtr(SnowboyDetect obj) { + return (obj == null) ? 0 : obj.swigCPtr; + } + + @SuppressWarnings("deprecation") + protected void finalize() { + delete(); + } + + public synchronized void delete() { + if (swigCPtr != 0) { + if (swigCMemOwn) { + swigCMemOwn = false; + snowboyJNI.delete_SnowboyDetect(swigCPtr); + } + swigCPtr = 0; + } + } + + public SnowboyDetect(String resource_filename, String model_str) { + this(snowboyJNI.new_SnowboyDetect(resource_filename, model_str), true); + } + + public boolean Reset() { + return snowboyJNI.SnowboyDetect_Reset(swigCPtr, this); + } + + public int RunDetection(String data, int is_end) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_0(swigCPtr, this, data, is_end); + } + + public int RunDetection(String data) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_1(swigCPtr, this, data); + } + + public int RunDetection(float[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_2(swigCPtr, this, data, array_length, is_end); + } + + public int RunDetection(float[] data, int array_length) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_3(swigCPtr, this, data, array_length); + } + + public int RunDetection(short[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_4(swigCPtr, this, data, array_length, is_end); + } + + public int RunDetection(short[] data, int array_length) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_5(swigCPtr, this, data, array_length); + } + + public int RunDetection(int[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_6(swigCPtr, this, data, array_length, is_end); + } + + public int RunDetection(int[] data, int array_length) { + return snowboyJNI.SnowboyDetect_RunDetection__SWIG_7(swigCPtr, this, data, array_length); + } + + public void SetSensitivity(String sensitivity_str) { + snowboyJNI.SnowboyDetect_SetSensitivity(swigCPtr, this, sensitivity_str); + } + + + + public String GetSensitivity() { + return snowboyJNI.SnowboyDetect_GetSensitivity(swigCPtr, this); + } + + public void SetAudioGain(float audio_gain) { + snowboyJNI.SnowboyDetect_SetAudioGain(swigCPtr, this, audio_gain); + } + + public void UpdateModel() { + snowboyJNI.SnowboyDetect_UpdateModel(swigCPtr, this); + } + + public int NumHotwords() { + return snowboyJNI.SnowboyDetect_NumHotwords(swigCPtr, this); + } + + public void ApplyFrontend(boolean apply_frontend) { + snowboyJNI.SnowboyDetect_ApplyFrontend(swigCPtr, this, apply_frontend); + } +} \ No newline at end of file diff --git a/app/src/main/java/ai/kitt/snowboy/SnowboyVad.java b/app/src/main/java/ai/kitt/snowboy/SnowboyVad.java new file mode 100644 index 0000000..5f11104 --- /dev/null +++ b/app/src/main/java/ai/kitt/snowboy/SnowboyVad.java @@ -0,0 +1,101 @@ +/* ---------------------------------------------------------------------------- + * This file was automatically generated by SWIG (http://www.swig.org). + * Version 4.0.1 + * + * Do not make changes to this file unless you know what you are doing--modify + * the SWIG interface file instead. + * ----------------------------------------------------------------------------- */ + +package ai.kitt.snowboy; + +public class SnowboyVad { + private transient long swigCPtr; + protected transient boolean swigCMemOwn; + + protected SnowboyVad(long cPtr, boolean cMemoryOwn) { + swigCMemOwn = cMemoryOwn; + swigCPtr = cPtr; + } + + + + protected static long getCPtr(SnowboyVad obj) { + return (obj == null) ? 0 : obj.swigCPtr; + } + + @SuppressWarnings("deprecation") + protected void finalize() { + delete(); + } + + public synchronized void delete() { + if (swigCPtr != 0) { + if (swigCMemOwn) { + swigCMemOwn = false; + snowboyJNI.delete_SnowboyVad(swigCPtr); + } + swigCPtr = 0; + } + } + + public SnowboyVad(String resource_filename) { + this(snowboyJNI.new_SnowboyVad(resource_filename), true); + } + + public boolean Reset() { + return snowboyJNI.SnowboyVad_Reset(swigCPtr, this); + } + + public int RunVad(String data, boolean is_end) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_0(swigCPtr, this, data, is_end); + } + + public int RunVad(String data) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_1(swigCPtr, this, data); + } + + public int RunVad(float[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_2(swigCPtr, this, data, array_length, is_end); + } + + public int RunVad(float[] data, int array_length) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_3(swigCPtr, this, data, array_length); + } + + public int RunVad(short[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_4(swigCPtr, this, data, array_length, is_end); + } + + public int RunVad(short[] data, int array_length) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_5(swigCPtr, this, data, array_length); + } + + public int RunVad(int[] data, int array_length, boolean is_end) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_6(swigCPtr, this, data, array_length, is_end); + } + + public int RunVad(int[] data, int array_length) { + return snowboyJNI.SnowboyVad_RunVad__SWIG_7(swigCPtr, this, data, array_length); + } + + public void SetAudioGain(float audio_gain) { + snowboyJNI.SnowboyVad_SetAudioGain(swigCPtr, this, audio_gain); + } + + public void ApplyFrontend(boolean apply_frontend) { + snowboyJNI.SnowboyVad_ApplyFrontend(swigCPtr, this, apply_frontend); + } + + public int SampleRate() { + return snowboyJNI.SnowboyVad_SampleRate(swigCPtr, this); + } + + public int NumChannels() { + return snowboyJNI.SnowboyVad_NumChannels(swigCPtr, this); + } + + public int BitsPerSample() { + return snowboyJNI.SnowboyVad_BitsPerSample(swigCPtr, this); + } + +} \ No newline at end of file diff --git a/app/src/main/java/ai/kitt/snowboy/snowboy.java b/app/src/main/java/ai/kitt/snowboy/snowboy.java new file mode 100644 index 0000000..efd3338 --- /dev/null +++ b/app/src/main/java/ai/kitt/snowboy/snowboy.java @@ -0,0 +1,28 @@ +/* ---------------------------------------------------------------------------- + * This file was automatically generated by SWIG (http://www.swig.org). + * Version 4.0.1 + * + * Do not make changes to this file unless you know what you are doing--modify + * the SWIG interface file instead. + * ----------------------------------------------------------------------------- */ + +package ai.kitt.snowboy; + +public class snowboy { + public enum MsgEnum { + MSG_VAD_END, + MSG_VAD_NOSPEECH, + MSG_VAD_SPEECH, + MSG_VOLUME_NOTIFY, + MSG_WAV_DATAINFO, + MSG_RECORD_START, + MSG_RECORD_STOP, + MSG_ACTIVE, + MSG_ERROR, + MSG_INFO; + + public static MsgEnum getMsgEnum(int i) { + return MsgEnum.values()[i]; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ai/kitt/snowboy/snowboyJNI.java b/app/src/main/java/ai/kitt/snowboy/snowboyJNI.java new file mode 100644 index 0000000..5af742d --- /dev/null +++ b/app/src/main/java/ai/kitt/snowboy/snowboyJNI.java @@ -0,0 +1,49 @@ +package ai.kitt.snowboy; + +/* ---------------------------------------------------------------------------- + * This file was automatically generated by SWIG (http://www.swig.org). + * Version 4.0.2 + * + * Do not make changes to this file unless you know what you are doing--modify + * the SWIG interface file instead. + * ----------------------------------------------------------------------------- */ + +public class snowboyJNI { + static { + System.loadLibrary("snowboy-detect-android"); + } + public final static native long new_SnowboyDetect(String jarg1, String jarg2); + public final static native boolean SnowboyDetect_Reset(long jarg1, SnowboyDetect jarg1_); + public final static native int SnowboyDetect_RunDetection__SWIG_0(long jarg1, SnowboyDetect jarg1_, String jarg2, int jarg3); + public final static native int SnowboyDetect_RunDetection__SWIG_1(long jarg1, SnowboyDetect jarg1_, String jarg2); + public final static native int SnowboyDetect_RunDetection__SWIG_2(long jarg1, SnowboyDetect jarg1_, float[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyDetect_RunDetection__SWIG_3(long jarg1, SnowboyDetect jarg1_, float[] jarg2, int jarg3); + public final static native int SnowboyDetect_RunDetection__SWIG_4(long jarg1, SnowboyDetect jarg1_, short[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyDetect_RunDetection__SWIG_5(long jarg1, SnowboyDetect jarg1_, short[] jarg2, int jarg3); + public final static native int SnowboyDetect_RunDetection__SWIG_6(long jarg1, SnowboyDetect jarg1_, int[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyDetect_RunDetection__SWIG_7(long jarg1, SnowboyDetect jarg1_, int[] jarg2, int jarg3); + public final static native void SnowboyDetect_SetSensitivity(long jarg1, SnowboyDetect jarg1_, String jarg2); + public final static native void SnowboyDetect_SetHighSensitivity(long jarg1, SnowboyDetect jarg1_, String jarg2); + public final static native String SnowboyDetect_GetSensitivity(long jarg1, SnowboyDetect jarg1_); + public final static native void SnowboyDetect_SetAudioGain(long jarg1, SnowboyDetect jarg1_, float jarg2); + public final static native void SnowboyDetect_UpdateModel(long jarg1, SnowboyDetect jarg1_); + public final static native int SnowboyDetect_NumHotwords(long jarg1, SnowboyDetect jarg1_); + public final static native void SnowboyDetect_ApplyFrontend(long jarg1, SnowboyDetect jarg1_, boolean jarg2); + public final static native void delete_SnowboyDetect(long jarg1); + public final static native long new_SnowboyVad(String jarg1); + public final static native boolean SnowboyVad_Reset(long jarg1, SnowboyVad jarg1_); + public final static native int SnowboyVad_RunVad__SWIG_0(long jarg1, SnowboyVad jarg1_, String jarg2, boolean jarg3); + public final static native int SnowboyVad_RunVad__SWIG_1(long jarg1, SnowboyVad jarg1_, String jarg2); + public final static native int SnowboyVad_RunVad__SWIG_2(long jarg1, SnowboyVad jarg1_, float[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyVad_RunVad__SWIG_3(long jarg1, SnowboyVad jarg1_, float[] jarg2, int jarg3); + public final static native int SnowboyVad_RunVad__SWIG_4(long jarg1, SnowboyVad jarg1_, short[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyVad_RunVad__SWIG_5(long jarg1, SnowboyVad jarg1_, short[] jarg2, int jarg3); + public final static native int SnowboyVad_RunVad__SWIG_6(long jarg1, SnowboyVad jarg1_, int[] jarg2, int jarg3, boolean jarg4); + public final static native int SnowboyVad_RunVad__SWIG_7(long jarg1, SnowboyVad jarg1_, int[] jarg2, int jarg3); + public final static native void SnowboyVad_SetAudioGain(long jarg1, SnowboyVad jarg1_, float jarg2); + public final static native void SnowboyVad_ApplyFrontend(long jarg1, SnowboyVad jarg1_, boolean jarg2); + public final static native int SnowboyVad_SampleRate(long jarg1, SnowboyVad jarg1_); + public final static native int SnowboyVad_NumChannels(long jarg1, SnowboyVad jarg1_); + public final static native int SnowboyVad_BitsPerSample(long jarg1, SnowboyVad jarg1_); + public final static native void delete_SnowboyVad(long jarg1); +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/App.kt b/app/src/main/java/com/kangrio/byd/assistant/App.kt new file mode 100644 index 0000000..e0fb6e2 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/App.kt @@ -0,0 +1,16 @@ +package com.kangrio.byd.assistant + +import android.app.Application +import com.kangrio.byd.assistant.ota.OtaUpdater +import com.kangrio.byd.assistant.service.VoiceWakeService +import com.kangrio.byd.assistant.util.Preferences + +class App : Application() { + override fun onCreate() { + super.onCreate() + Preferences.init(this) + OtaUpdater.createNotificationChannel(this) + OtaUpdater.checkUpdateInBackground(this, force = false) + VoiceWakeService.startService(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/Constant.kt b/app/src/main/java/com/kangrio/byd/assistant/Constant.kt new file mode 100644 index 0000000..7624812 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/Constant.kt @@ -0,0 +1,20 @@ +package com.kangrio.byd.assistant + +object Constant { + const val GOOGLE_APP_PACKAGE = "com.google.android.googlequicksearchbox" + const val CHATGPT_APP_PACKAGE = "com.openai.chatgpt" + const val CHATGPT_APP_ASSISTANT_CLASS_NAME = "com.openai.voice.assistant.AssistantActivity" + const val PREFS_START_HOTWORD = "PREFS_START_HOTWORD" + const val PREFS_HOTWORD_MODEL_NAME = "PREFS_HOTWORD_MODEL_NAME" + const val PREFS_HOTWORD_SENSITIVITY = "PREFS_HOTWORD_SENSITIVITY" + const val PREFS_HOTWORD_GAIN = "PREFS_HOTWORD_GAIN" + const val PREFS_PLAY_DING_ON_START = "PREFS_PLAY_DING_ON_START" + const val PREFS_ASSISTANT_PACKAGE_COMPONENT = "PREFS_ASSISTANT_PACKAGE_COMPONENT" + const val PREFS_LAST_OTA_CHECK_TIME = "PREFS_LAST_OTA_CHECK_TIME" + const val PREFS_LATEST_OTA_VERSION = "PREFS_LATEST_OTA_VERSION" + + const val GITHUB_OTA_URL = "https://api.github.com/repos/kangrio/Assistant/releases/latest" + const val OTA_CHECK_INTERVAL_MS = 3 * 60 * 60 * 1000L // 3 hours + const val OTA_NOTIFICATION_CHANNEL_ID = "ota_updates" + const val OTA_NOTIFICATION_ID = 2001 +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/MainActivity.kt b/app/src/main/java/com/kangrio/byd/assistant/MainActivity.kt deleted file mode 100644 index e3528f5..0000000 --- a/app/src/main/java/com/kangrio/byd/assistant/MainActivity.kt +++ /dev/null @@ -1,594 +0,0 @@ -package com.kangrio.byd.assistant - -import android.Manifest -import android.content.Context -import android.os.Build -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.BorderStroke -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.layout.width -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.material.icons.Icons -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -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.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.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -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.PreviewScreenSizes -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.kangrio.byd.assistant.ui.theme.AssistantTheme -import com.kangrio.byd.assistant.util.Utils -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - AssistantTheme { - AssistantApp() - } - } - } - - override fun onPause() { - super.onPause() - if (Utils.isGranted(this, Manifest.permission.WRITE_SECURE_SETTINGS) && - Utils.setupCompleted(this) - ) { - finishAffinity() - } - } -} - -@PreviewScreenSizes -@Composable -fun AssistantApp() { - Scaffold( - modifier = Modifier.fillMaxSize(), - ) { innerPadding -> - Home(modifier = Modifier.padding(innerPadding)) - } -} - -data class Processing( - val processing: Boolean, - val message: String -) - -@Composable -fun Home(modifier: Modifier = Modifier) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val lifecycleOwner = LocalLifecycleOwner.current - - var processing by remember { mutableStateOf(Processing(false, "")) } - - var isGoogleAppInstalled by remember { - mutableStateOf(Utils.isGoogleAppInstalled(context)) - } - - var isGranted by remember { - mutableStateOf( - Utils.isGranted( - context, - Manifest.permission.WRITE_SECURE_SETTINGS - ) - ) - } - - var isEnabledVoiceAssistant by remember { - mutableStateOf( - Utils.isEnableVoiceAssistant(context) - ) - } - - var isAutoStart by remember { - mutableStateOf( - Utils.isGrantedAutoStart(context) - ) - } - - // Refresh states automatically when coming back to the screen - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - isGoogleAppInstalled = Utils.isGoogleAppInstalled(context) - isGranted = Utils.isGranted( - context, - Manifest.permission.WRITE_SECURE_SETTINGS - ) - if (isGranted) { - isEnabledVoiceAssistant = Utils.isEnableVoiceAssistant(context) - } - isAutoStart = Utils.isGrantedAutoStart(context) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - Surface( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - shape = RoundedCornerShape(28.dp), - tonalElevation = 2.dp, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant - ) - ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Title & Description Header - Text( - text = "Assistant Setup", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 8.dp) - ) - - Text( - text = "Follow each detailed step below to configure and test Google Assistant as your primary voice assistant.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp, bottom = 24.dp) - ) - - // Step 1: Auto Start Check - StepCard( - stepNumber = 1, - icon = Icons.Default.RestartAlt, - title = "Auto Start", - statusText = "Check is allowed auto start", - isCompleted = isAutoStart, - isActive = !isAutoStart, - description = "Allow auto start when device is rebooted.", - detailText = "Google Assistant needs to be re-enabled after every reboot.", - buttonText = "Allow", - onButtonClick = { - Utils.markAutoStartTime(context) - Utils.openAutoStartSettings(context) - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Step 2: Google App Check - StepCard( - stepNumber = 2, - icon = Icons.Default.Android, - title = "Google App Installation", - statusText = if (isGoogleAppInstalled) "App Installed" else "App Missing", - isCompleted = isGoogleAppInstalled, - isActive = !isGoogleAppInstalled, - description = "Verifies if the Google App is installed on this device to handle voice command intents.", - detailText = "Package Name:\ncom.google.android.googlequicksearchbox\n\nStatus:\n${if (isGoogleAppInstalled) "Installed and ready" else "Not installed. Install via Store below."}", - buttonText = if (isGoogleAppInstalled) "Installed" else "Install Google App", - onButtonClick = { - Utils.openGoogleAppInStore(context) - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Step 3: System Permission - StepCard( - stepNumber = 3, - icon = Icons.Default.Key, - title = "Grant Write Secure Settings Permission", - statusText = if (isGranted) "Permission Granted" else if (isGoogleAppInstalled) "Action Required" else "Blocked by Step 1", - isCompleted = isGranted, - isActive = isGoogleAppInstalled && !isGranted, - description = "Required to write system-level assistant configuration keys into Android Secure Settings.", - detailText = "Permission Required:\nandroid.permission.WRITE_SECURE_SETTINGS\n\nAutomatic Method:\nUses dadb to connect locally on device over ADB socket and execute pm grant.", - manualAdbCommand = "adb shell pm grant ${context.packageName} ${Manifest.permission.WRITE_SECURE_SETTINGS}", - buttonText = if (isGranted) "Granted" else "Grant via ADB", - onButtonClick = { - scope.launch { - processing = Processing( - true, - "Connecting via ADB to grant permission:\n${Manifest.permission.WRITE_SECURE_SETTINGS}" - ) - Utils.adbRequestPermission( - context, - Manifest.permission.WRITE_SECURE_SETTINGS - ) - delay(1200) - processing = Processing(false, "") - isGranted = Utils.isGranted( - context, - Manifest.permission.WRITE_SECURE_SETTINGS - ) - } - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Step 4: Enable Voice Assistant - StepCard( - stepNumber = 4, - icon = Icons.Default.Settings, - title = "Configure Voice Assistant Service", - statusText = if (isEnabledVoiceAssistant) "Service Enabled" else if (isGranted) "Ready to Enable" else "Blocked by Step 2", - isCompleted = isEnabledVoiceAssistant, - isActive = isGranted && !isEnabledVoiceAssistant, - description = "Configures Google Assistant (GsaVoiceInteractionService) as your active default voice interaction service.", - detailText = "Target Component:\ncom.google.android.googlequicksearchbox/\ncom.google.android.voiceinteraction.GsaVoiceInteractionService\n\nSettings Updated:\n- Settings.Secure.assistant\n- Settings.Secure.voice_interaction_service", - buttonText = if (isEnabledVoiceAssistant) "Enabled" else "Enable Assistant", - onButtonClick = { - scope.launch { - processing = Processing( - true, - "Writing voice interaction settings for Google Assistant..." - ) - Utils.enableVoiceAssistant(context) - delay(1000) - isEnabledVoiceAssistant = Utils.isEnableVoiceAssistant(context) - processing = Processing(false, "") - } - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Step 5: Test Shortcut - StepCard( - stepNumber = 5, - icon = Icons.Default.PlayArrow, - title = "Test Voice Assistant Launcher", - statusText = if (isEnabledVoiceAssistant) "Ready to Test" else "Blocked by Step 4", - isCompleted = false, - isActive = isEnabledVoiceAssistant, - description = "Launches the voice command intent to verify Google Assistant opens properly.", - detailText = "Intent Action:\nandroid.intent.action.VOICE_COMMAND\n\nBehavior:\nLaunching this application in the future will automatically trigger Google Assistant.", - buttonText = "Launch Test", - onButtonClick = { - Utils.startVoiceAssistant(context) - } - ) - } - } - - if (processing.processing) { - AlertDialog( - onDismissRequest = {}, - confirmButton = {}, - text = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = processing.message, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - ) - } - } -} - -@Composable -fun StepCard( - stepNumber: Int, - icon: ImageVector, - title: String, - statusText: String, - isCompleted: Boolean, - isActive: Boolean, - description: String, - detailText: String? = null, - manualAdbCommand: String? = null, - buttonText: String, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val borderColor = when { - isCompleted -> MaterialTheme.colorScheme.primary - isActive -> MaterialTheme.colorScheme.outline - else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - } - - val containerColor = when { - isCompleted -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - isActive -> MaterialTheme.colorScheme.surface - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, borderColor), - colors = CardDefaults.cardColors(containerColor = containerColor) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp) - ) { - // Step Header: Badge + Title + Status Pill - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background( - if (isCompleted) MaterialTheme.colorScheme.primary - else if (isActive) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.outlineVariant - ), - contentAlignment = Alignment.Center - ) { - if (isCompleted) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Completed", - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp) - ) - } else { - Text( - text = "$stepNumber", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isActive) MaterialTheme.colorScheme.onSecondaryContainer - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = if (isActive || isCompleted) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - - Surface( - shape = RoundedCornerShape(12.dp), - color = when { - isCompleted -> MaterialTheme.colorScheme.primaryContainer - isActive -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant - }, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = statusText, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium, - color = when { - isCompleted -> MaterialTheme.colorScheme.onPrimaryContainer - isActive -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - }, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Step Description - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = if (isActive || isCompleted) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - - // Technical Details Block - if (detailText != null) { - Spacer(modifier = Modifier.height(12.dp)) - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = "Technical Details", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "Technical Details", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = detailText, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - // Manual ADB Command Box - if (!isCompleted && manualAdbCommand != null) { - Spacer(modifier = Modifier.height(10.dp)) - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = "Manual ADB", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "Manual ADB Command Alternative", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = manualAdbCommand, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onErrorContainer - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton( - onClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText( - "ADB Command", - manualAdbCommand - ) - clipboard.setPrimaryClip(clip) - }, - enabled = true, - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(text = "Copy Command") - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Action Button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - if (isCompleted) { - OutlinedButton( - onClick = {}, - enabled = false, - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(text = buttonText) - } - } else { - Button( - onClick = onButtonClick, - enabled = isActive, - shape = RoundedCornerShape(12.dp) - ) { - Text(text = buttonText) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/SplashActivity.kt b/app/src/main/java/com/kangrio/byd/assistant/StartActivity.kt similarity index 55% rename from app/src/main/java/com/kangrio/byd/assistant/SplashActivity.kt rename to app/src/main/java/com/kangrio/byd/assistant/StartActivity.kt index 2613d10..282ea74 100644 --- a/app/src/main/java/com/kangrio/byd/assistant/SplashActivity.kt +++ b/app/src/main/java/com/kangrio/byd/assistant/StartActivity.kt @@ -1,19 +1,26 @@ package com.kangrio.byd.assistant -import android.Manifest import android.app.Activity import android.content.Intent import android.os.Bundle import com.kangrio.byd.assistant.util.Utils +import com.kangrio.byd.assistant.activity.PermissionOnboardingActivity +import com.kangrio.byd.assistant.service.VoiceWakeService class StartActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + VoiceWakeService.startService(this) + } + + override fun onResume() { + super.onResume() if (Utils.setupCompleted(this)) { Utils.startVoiceAssistant(this) } else { - startActivity(Intent(this, MainActivity::class.java)) + startActivity(Intent(this, PermissionOnboardingActivity::class.java)) } - finishAffinity() + finish() } } \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/activity/PermissionOnboardingActivity.kt b/app/src/main/java/com/kangrio/byd/assistant/activity/PermissionOnboardingActivity.kt new file mode 100644 index 0000000..fec9c51 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/activity/PermissionOnboardingActivity.kt @@ -0,0 +1,35 @@ +package com.kangrio.byd.assistant.activity + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.kangrio.byd.assistant.ui.onboarding.PermissionOnboardingScreen +import com.kangrio.byd.assistant.ui.theme.AssistantTheme +import com.kangrio.byd.assistant.util.Utils + +class PermissionOnboardingActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AssistantTheme { + PermissionOnboardingScreen( + onOnboardingFinished = { + finishOnboarding() + } + ) + } + } + } + + private fun finishOnboarding() { + if (Utils.setupCompleted(this)) { + Utils.startVoiceAssistant(this) + } + startActivity(Intent(this, SettingsActivity::class.java)) + finish() + } +} diff --git a/app/src/main/java/com/kangrio/byd/assistant/activity/SettingsActivity.kt b/app/src/main/java/com/kangrio/byd/assistant/activity/SettingsActivity.kt new file mode 100644 index 0000000..77e4394 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/activity/SettingsActivity.kt @@ -0,0 +1,931 @@ +package com.kangrio.byd.assistant.activity + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import com.kangrio.byd.assistant.ota.OtaUpdater +import com.kangrio.byd.assistant.data.ReleaseInfo +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.kangrio.byd.assistant.service.VoiceWakeService +import com.kangrio.byd.assistant.ui.composable.AppIcon +import com.kangrio.byd.assistant.ui.theme.AssistantTheme +import com.kangrio.byd.assistant.util.Preferences +import com.kangrio.byd.assistant.util.SnowboyTrainClient +import com.kangrio.byd.assistant.util.Utils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private const val WAKE_WORD_MODEL_NAME = "user_custom" +private const val SEASALT_BASE_URL = "https://snowboy.jolanrensen.nl" // snowboy-seasalt host +private const val MIN_SAMPLES = 3 + +/** Drives the inline record panel: which controls are enabled and what's shown. */ +sealed class RecordPhase { + data object Idle : RecordPhase() + data object Recording : RecordPhase() + data object Uploading : RecordPhase() + data class Trained(val pmdlFile: File) : RecordPhase() + data class Failure(val message: String) : RecordPhase() +} + +/** Thin wrapper around MediaRecorder for one take at a time. */ +private class SampleRecorder( + private val context: android.content.Context, + private val outputDir: File +) { + private var recorder: MediaRecorder? = null + private var currentFile: File? = null + + fun start(): File { + if (!outputDir.exists()) outputDir.mkdirs() + val file = File(outputDir, "sample_${System.currentTimeMillis()}.m4a") + currentFile = file + + @Suppress("DEPRECATION") + val newRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + newRecorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorder = newRecorder + return file + } + + /** Stops the current take and returns the recorded file, or null if nothing was captured. */ + fun stop(): File? { + return try { + recorder?.apply { + stop() + release() + } + recorder = null + currentFile + } catch (e: Exception) { + recorder?.release() + recorder = null + currentFile?.delete() + null + } + } + + /** Aborts the current take, discarding any partial recording. */ + fun cancel() { + try { + recorder?.stop() + } catch (_: Exception) { + // recorder never produced valid output; nothing to clean up beyond release + } + recorder?.release() + recorder = null + currentFile?.delete() + currentFile = null + } +} + +class SettingsActivity : ComponentActivity() { + + private val trainClient = SnowboyTrainClient(baseUrl = SEASALT_BASE_URL) + private lateinit var sampleRecorder: SampleRecorder + + private var showTrainPanel = mutableStateOf(false) + private var recordPhase = mutableStateOf(RecordPhase.Idle) + private var samples = mutableStateOf>(emptyList()) + private var initialOtaRelease = mutableStateOf(null) + + private val requestMicPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) beginRecording() else { + Toast.makeText(this, "Microphone permission is required to record", Toast.LENGTH_SHORT).show() + } + } + + private val savePmdlLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + val pmdlFile = (recordPhase.value as? RecordPhase.Trained)?.pmdlFile + if (uri != null && pmdlFile != null) { + contentResolver.openOutputStream(uri)?.use { out -> + pmdlFile.inputStream().use { it.copyTo(out) } + } + Toast.makeText(this, "Saved $WAKE_WORD_MODEL_NAME.pmdl", Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sampleRecorder = SampleRecorder( + context = applicationContext, + outputDir = cacheDir.resolve("wake_samples") + ) + + val otaVersion = intent.getStringExtra("EXTRA_OTA_VERSION") + val otaUrl = intent.getStringExtra("EXTRA_OTA_URL") + val otaName = intent.getStringExtra("EXTRA_OTA_NAME") + val otaBody = intent.getStringExtra("EXTRA_OTA_BODY") + if (!otaVersion.isNullOrEmpty() && !otaUrl.isNullOrEmpty()) { + initialOtaRelease.value = ReleaseInfo( + tagName = otaVersion, + versionName = otaVersion.removePrefix("v").removePrefix("V"), + title = otaVersion, + body = otaBody ?: "", + htmlUrl = "", + downloadUrl = otaUrl, + apkName = otaName ?: "Assistant.apk" + ) + } + + setContent { + AssistantTheme { + val panelOpen by showTrainPanel + val phase by recordPhase + val currentSamples by samples + val otaRelease by initialOtaRelease + + Scaffold(modifier = Modifier.fillMaxSize()) { paddingValues -> + SettingsScreen( + modifier = Modifier.padding(paddingValues), + initialOtaRelease = otaRelease, + onStateToggle = { state -> VoiceWakeService.setState(this, state) }, + onPlayDingToggle = { state -> Preferences.playDingOnStart = state }, + onSensitivityChange = { VoiceWakeService.setSensitivity(this, it) }, + onGainChange = { VoiceWakeService.setGain(this, it) }, + recordPhase = phase, + sampleCount = currentSamples.size, + onModelChanged = { model -> VoiceWakeService.setModel(this, model) }, + onStartRecordClick = { onStartOrStopRecord(phase) }, + onSubmitClick = { submitSamples(currentSamples) }, + onNewClick = { resetPanel() }, + ) + } + } + } + } + + override fun onStop() { + super.onStop() + if (recordPhase.value is RecordPhase.Recording) { + sampleRecorder.cancel() + recordPhase.value = RecordPhase.Idle + } + finish() + } + + private fun onStartOrStopRecord(phase: RecordPhase) { + if (phase is RecordPhase.Recording) { + val file = sampleRecorder.stop() + recordPhase.value = RecordPhase.Idle + if (file != null) { + samples.value += file + } else { + Toast.makeText(this, "Recording failed, try again", Toast.LENGTH_SHORT).show() + } + return + } + + val hasPermission = ContextCompat.checkSelfPermission( + this, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) beginRecording() else requestMicPermission.launch(Manifest.permission.RECORD_AUDIO) + } + + private fun beginRecording() { + try { + sampleRecorder.start() + recordPhase.value = RecordPhase.Recording + } catch (e: Exception) { + recordPhase.value = RecordPhase.Failure(e.message ?: "Couldn't start recording") + } + } + + private fun submitSamples(currentSamples: List) { + if (currentSamples.size < MIN_SAMPLES) { + Toast.makeText(this, "Record at least $MIN_SAMPLES samples first", Toast.LENGTH_SHORT).show() + return + } + + recordPhase.value = RecordPhase.Uploading + lifecycleScope.launch { + when (val result = trainClient.train( + modelName = WAKE_WORD_MODEL_NAME, + samples = currentSamples, + outputDir = filesDir.resolve("snowboy/models") + )) { + is SnowboyTrainClient.TrainResult.Success -> { + VoiceWakeService.setModel(this@SettingsActivity, result.pmdlFile.nameWithoutExtension) + recordPhase.value = RecordPhase.Trained(result.pmdlFile) + Toast.makeText(this@SettingsActivity, "Wake word trained", Toast.LENGTH_SHORT).show() + } + is SnowboyTrainClient.TrainResult.Failure -> { + recordPhase.value = RecordPhase.Failure(result.message) + Toast.makeText(this@SettingsActivity, result.message, Toast.LENGTH_LONG).show() + } + } + } + } + + /** Clears recorded takes and any trained/failed result so a fresh session can start. */ + private fun resetPanel() { + if (recordPhase.value is RecordPhase.Recording) sampleRecorder.cancel() + samples.value.forEach { it.delete() } + samples.value = emptyList() + recordPhase.value = RecordPhase.Idle + } +} + +/** + * Settings screen, matching the app wireframe: + * outer rounded frame -> "Settings" title pill -> inner rounded panel with + * setting rows ("State" toggle, "Train your voice" action, expandable record + * panel) -> footer pill. + */ +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier, + initialOtaRelease: ReleaseInfo? = null, + onStateToggle: (Boolean) -> Unit, + onPlayDingToggle: (Boolean) -> Unit = {}, + onSensitivityChange: (Float) -> Unit = {}, + onGainChange: (Float) -> Unit = {}, + recordPhase: RecordPhase = RecordPhase.Idle, + sampleCount: Int = 0, + onModelChanged: (String) -> Unit = { _-> }, + onStartRecordClick: () -> Unit = {}, + onSubmitClick: () -> Unit = {}, + onNewClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val outline = MaterialTheme.colorScheme.outlineVariant + + var showAssistantDialog by remember { mutableStateOf(false) } + var showAssistantPermissionsDialog by remember { mutableStateOf(false) } + var assistantApps by remember { + mutableStateOf(Utils.listAssistantPackages(context)) + } + var selectedAssistantApp by remember { mutableStateOf(Utils.getCurrentAssistantApp(context)) } + + var isWriteSecureSettingsGranted by remember { mutableStateOf(Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS)) } + + var expandedModels by remember { mutableStateOf(false) } + var selectedModel by remember { mutableStateOf(Preferences.hotwordModelName) } + + var isStateOn by remember { mutableStateOf(Preferences.startHotword) } + var isPlayDing by remember { mutableStateOf(Preferences.playDingOnStart) } + var sensitivity by remember { mutableFloatStateOf(Preferences.hotwordSensitivity) } + var gain by remember { mutableFloatStateOf(Preferences.hotwordGain) } + + var isCheckingUpdate by remember { mutableStateOf(false) } + var availableUpdate by remember { mutableStateOf(initialOtaRelease) } + var showUpdateDialog by remember { mutableStateOf(initialOtaRelease != null) } + var isDownloadingUpdate by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableFloatStateOf(0f) } + + val models = remember(selectedModel) { + context.filesDir.resolve("snowboy/models") + .listFiles() + ?.filter { it.isFile && it.extension.equals("pmdl", ignoreCase = true) } + ?: emptyList() + } + + LaunchedEffect(lifecycleOwner) { + while (true) { + isWriteSecureSettingsGranted = Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) + delay(1_000L.milliseconds) + } + } + + Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .border(1.dp, outline, RoundedCornerShape(28.dp)) + .padding(20.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TitlePill(text = "Settings") + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + SettingRow( + label = "Permission Onboarding", + description = "Review and manage all required app permissions." + ) { + FilledTonalButton( + onClick = { + context.startActivity(Intent(context, PermissionOnboardingActivity::class.java)) + } + ) { + Text("Open Wizard") + } + } + + SettingRow( + label = "Assistant app", + description = "Select the assistant app to use." + ) { + FilledTonalButton( + onClick = { + if (!isWriteSecureSettingsGranted) { + Toast.makeText(context, "Write Secure Settings permission is required", Toast.LENGTH_SHORT).show() + return@FilledTonalButton + } + showAssistantDialog = true + } + ) { + Text(selectedAssistantApp.name) + } + } + if (Utils.getNotificationListenerComponentName(context, selectedAssistantApp.packageName) != null) { + SettingRow( + label = "Assistant Permissions", + description = "Give the assistant app required permissions." + ) { + FilledTonalButton( + onClick = { + if (!isWriteSecureSettingsGranted) { + Toast.makeText(context, "Write Secure Settings permission is required", Toast.LENGTH_SHORT).show() + return@FilledTonalButton + } + showAssistantPermissionsDialog = true + } + ) { + Text("Grant") + } + } + } + + SettingRow( + label = "Voice Detection", + description = "Enable or disable wake-word detection." + ) { + Switch( + checked = isStateOn, + onCheckedChange = { + isStateOn = it + onStateToggle(it) + }, + modifier = Modifier.semantics { + contentDescription = "State toggle, ${if (isStateOn) "on" else "off"}" + } + ) + } + + SettingRow( + label = "Play Sound", + description = "Play a sound when voice detection starts." + ) { + Switch( + checked = isPlayDing, + onCheckedChange = { + isPlayDing = it + onPlayDingToggle(it) + }, + modifier = Modifier.semantics { + contentDescription = + "Play ding toggle, ${if (isPlayDing) "on" else "off"}" + } + ) + } + + SettingRow( + label = "Detection Sensitivity", + description = "Adjust how easily the wake word is detected." + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Slider( + modifier = Modifier + .width(240.dp) + .padding(end = 12.dp), + value = sensitivity, + onValueChange = { + sensitivity = it + }, + onValueChangeFinished = { + onSensitivityChange(sensitivity) + }, + valueRange = 0.1f..1f, + steps = 8, + ) + Text( + text = "%.1f".format(sensitivity), + ) + } + } + + SettingRow( + label = "Microphone Gain", + description = "Adjust the microphone input volume." + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Slider( + modifier = Modifier + .width(240.dp) + .padding(end = 12.dp), + value = gain, + onValueChange = { + gain = it + }, + onValueChangeFinished = { + onGainChange(gain) + }, + valueRange = 0.1f..2.0f, + steps = 19, + ) + Text( + text = "%.1f".format(gain), + ) + } + } + + SettingRow( + label = "Check for Updates", + description = "Current version: v${OtaUpdater.getCurrentVersionName(context)}" + ) { + FilledTonalButton( + onClick = { + if (isCheckingUpdate || isDownloadingUpdate) return@FilledTonalButton + isCheckingUpdate = true + scope.launch { + val release = OtaUpdater.checkForUpdate(context, force = true) + isCheckingUpdate = false + if (release != null) { + availableUpdate = release + showUpdateDialog = true + } else { + Toast.makeText( + context, + "Assistant is up-to-date (v${OtaUpdater.getCurrentVersionName(context)})", + Toast.LENGTH_SHORT + ).show() + } + } + }, + enabled = !isCheckingUpdate && !isDownloadingUpdate + ) { + if (isCheckingUpdate) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Check Now") + } + } + } + + HorizontalDivider(color = outline) + + SettingRow( + label = "Record Your Custom Wake Word", + description = "Record at least 3 samples of your custom wake word to train your voice model." + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + + Box { + FilledTonalButton( + onClick = { expandedModels = true } + ) { + Text(selectedModel) + } + + DropdownMenu( + expanded = expandedModels, + onDismissRequest = { expandedModels = false } + ) { + models.forEach { model -> + DropdownMenuItem( + text = { Text(model.nameWithoutExtension) }, + onClick = { + onModelChanged(model.nameWithoutExtension) + selectedModel = model.nameWithoutExtension + expandedModels = false + } + ) + } + } + } + } + } + + RecordPanel( + phase = recordPhase, + sampleCount = sampleCount, + onStartRecordClick = onStartRecordClick, + onSubmitClick = { + onSubmitClick() + selectedModel = WAKE_WORD_MODEL_NAME + }, + onNewClick = onNewClick, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TitlePill(text = "Created by @KangRio") + } + } + + if (showAssistantDialog) { + AlertDialog( + onDismissRequest = { showAssistantDialog = false }, + title = { + Text("Select Assistant App") + }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + assistantApps.forEach { assistant -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showAssistantDialog = false + Utils.enableVoiceAssistant(context, assistant.componentName) + Preferences.assistantPackageComponent = assistant.componentName + selectedAssistantApp = assistant + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon( + packageName = assistant.packageName, + modifier = Modifier.size(40.dp) + ) + + Spacer(Modifier.width(12.dp)) + + Text(assistant.name) + } + } + } + }, + confirmButton = {} + ) + } + + if (showAssistantPermissionsDialog) { + var componentName by remember { mutableStateOf(Utils.getNotificationListenerComponentName(context, selectedAssistantApp.packageName)) } + if (componentName != null) { + AlertDialog( + onDismissRequest = { showAssistantPermissionsDialog = false }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon( + packageName = selectedAssistantApp.packageName, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(12.dp)) + Text("Grant Assistant Permissions") + } + }, + text = { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showAssistantPermissionsDialog = false + } + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + var granted by remember { mutableStateOf(Utils.isNotificationListenerEnabled(context, selectedAssistantApp.packageName)) } + Text("Notification Listener") + Switch( + checked = granted, + onCheckedChange = { + scope.launch { + if (it) { + Utils.grantNotificationListener(context, selectedAssistantApp.packageName) + delay(1000.milliseconds) + granted = Utils.isNotificationListenerEnabled(context, selectedAssistantApp.packageName) + } + } + } + ) + } + } + }, + confirmButton = {} + ) + } else { + showAssistantPermissionsDialog = false + Toast.makeText(context, "No need to grant permissions", Toast.LENGTH_SHORT).show() + } + } + + if (showUpdateDialog && availableUpdate != null) { + val update = availableUpdate!! + AlertDialog( + onDismissRequest = { + if (!isDownloadingUpdate) { + showUpdateDialog = false + } + }, + title = { + Text("Update Available: ${update.tagName}") + }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + text = if (update.body.isNotBlank()) update.body else "A new version of Assistant is available.", + style = MaterialTheme.typography.bodyMedium + ) + if (isDownloadingUpdate) { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Downloading... ${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (isDownloadingUpdate) return@Button + isDownloadingUpdate = true + downloadProgress = 0f + scope.launch { + val file = OtaUpdater.downloadApk( + context = context, + downloadUrl = update.downloadUrl, + fileName = update.apkName.ifEmpty { "Assistant_${update.tagName}.apk" }, + onProgress = { progress -> + downloadProgress = progress + } + ) + isDownloadingUpdate = false + if (file != null && file.exists()) { + showUpdateDialog = false + OtaUpdater.installApk(context, file) + } else { + Toast.makeText(context, "Failed to download update", Toast.LENGTH_SHORT).show() + } + } + }, + enabled = !isDownloadingUpdate + ) { + Text(if (isDownloadingUpdate) "Downloading..." else "Download & Install") + } + }, + dismissButton = { + if (!isDownloadingUpdate) { + OutlinedButton(onClick = { showUpdateDialog = false }) { + Text("Later") + } + } + } + ) + } +} + +@Composable +private fun RecordPanel( + phase: RecordPhase, + sampleCount: Int, + onStartRecordClick: () -> Unit, + onSubmitClick: () -> Unit, + onNewClick: () -> Unit, +) { + val scope = rememberCoroutineScope() + val isRecording = phase is RecordPhase.Recording + val isUploading = phase is RecordPhase.Uploading + val isTrained = phase is RecordPhase.Trained + val isBusy = isRecording || isUploading + + var recordText by remember { mutableStateOf("Start") } + var isWaiting by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(14.dp)) + .padding(16.dp) + ) { + Text( + text = statusText(phase, sampleCount), + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + scope.launch { + isWaiting = true + var timer = 2 + repeat(timer) { + recordText = "Wait ${timer - it}..." + delay(1.seconds) + } + + onStartRecordClick() + + timer = 3 + repeat(timer) { + recordText = "Recording ${timer - it}..." + delay(1.seconds) + } + + onStartRecordClick() + recordText = "Start" + isWaiting = false + } + }, + enabled = !isWaiting && !isUploading && !isTrained && !isRecording + ) { + Text(recordText) + } + + FilledTonalButton( + onClick = onSubmitClick, + enabled = !isBusy && !isTrained && sampleCount >= MIN_SAMPLES + ) { + if (isUploading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Submit") + } + } + + OutlinedButton( + onClick = onNewClick, + enabled = !isBusy && sampleCount > 0 + ) { + Text("New") + } + } + } +} + +private fun statusText(phase: RecordPhase, sampleCount: Int): String = when (phase) { + RecordPhase.Idle -> "$sampleCount / $MIN_SAMPLES samples recorded" + RecordPhase.Recording -> "Recording... speak your word" + RecordPhase.Uploading -> "Uploading and training..." + is RecordPhase.Trained -> "Model trained — tap New to record again" + is RecordPhase.Failure -> "Failed: ${phase.message}" +} + +@Composable +private fun TitlePill(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) + .padding(horizontal = 24.dp, vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } +} + +@Composable +private fun SettingRow( + label: String, + description: String = "", + content: @Composable () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(if (description.isNotEmpty()) 64.dp else 56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.padding(end = 12.dp)) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + if (description.isNotEmpty()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + + content() + } +} + +@Preview(showBackground = true, widthDp = 480, heightDp = 480) +@Composable +private fun SettingsScreenPreview() { + var stateOn by remember { mutableStateOf(true) } + + MaterialTheme { + SettingsScreen( + onStateToggle = { stateOn = it }, + sampleCount = 2, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/data/AssistantApp.kt b/app/src/main/java/com/kangrio/byd/assistant/data/AssistantApp.kt new file mode 100644 index 0000000..6a31a2e --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/data/AssistantApp.kt @@ -0,0 +1,8 @@ +package com.kangrio.byd.assistant.data + +data class AssistantApp( + val name: String = "", + val packageName: String = "", + val className: String = "", + val componentName: String = "$packageName/$className" +) diff --git a/app/src/main/java/com/kangrio/byd/assistant/data/GitHubAsset.kt b/app/src/main/java/com/kangrio/byd/assistant/data/GitHubAsset.kt new file mode 100644 index 0000000..9566748 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/data/GitHubAsset.kt @@ -0,0 +1,12 @@ +package com.kangrio.byd.assistant.data + +import com.google.gson.annotations.SerializedName + +data class GitHubAsset( + @SerializedName("name") + val name: String, + @SerializedName("browser_download_url") + val browser_download_url: String, + @SerializedName("size") + val size: Long +) diff --git a/app/src/main/java/com/kangrio/byd/assistant/data/GitHubRelease.kt b/app/src/main/java/com/kangrio/byd/assistant/data/GitHubRelease.kt new file mode 100644 index 0000000..eaf177a --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/data/GitHubRelease.kt @@ -0,0 +1,18 @@ +package com.kangrio.byd.assistant.data + +import com.google.gson.annotations.SerializedName + +data class GitHubRelease( + @SerializedName("tag_name") + val tag_name: String, + @SerializedName("name") + val name: String?, + @SerializedName("prerelease") + val prerelease: Boolean, + @SerializedName("html_url") + val html_url: String, + @SerializedName("body") + val body: String? = null, + @SerializedName("assets") + val assets: List +) diff --git a/app/src/main/java/com/kangrio/byd/assistant/data/ReleaseInfo.kt b/app/src/main/java/com/kangrio/byd/assistant/data/ReleaseInfo.kt new file mode 100644 index 0000000..8f4beaf --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/data/ReleaseInfo.kt @@ -0,0 +1,13 @@ +package com.kangrio.byd.assistant.data + +data class ReleaseInfo( + val tagName: String, + val versionName: String, + val title: String, + val body: String, + val htmlUrl: String, + val downloadUrl: String, + val apkName: String, + val size: Long = 0L, + val publishedAt: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/ota/GitHubApi.kt b/app/src/main/java/com/kangrio/byd/assistant/ota/GitHubApi.kt new file mode 100644 index 0000000..82c2f0a --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/ota/GitHubApi.kt @@ -0,0 +1,19 @@ +package com.kangrio.byd.assistant.ota + +import com.kangrio.byd.assistant.data.GitHubRelease +import retrofit2.http.GET +import retrofit2.http.Path + +interface GitHubApi { + @GET("repos/{owner}/{repo}/releases/latest") + suspend fun getLatestRelease( + @Path("owner") owner: String, + @Path("repo") repo: String + ): GitHubRelease + + @GET("repos/{owner}/{repo}/releases") + suspend fun getAllReleases( + @Path("owner") owner: String, + @Path("repo") repo: String + ): List +} diff --git a/app/src/main/java/com/kangrio/byd/assistant/ota/OtaUpdater.kt b/app/src/main/java/com/kangrio/byd/assistant/ota/OtaUpdater.kt new file mode 100644 index 0000000..6d1c739 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/ota/OtaUpdater.kt @@ -0,0 +1,312 @@ +package com.kangrio.byd.assistant.ota + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import com.kangrio.byd.assistant.Constant +import com.kangrio.byd.assistant.R +import com.kangrio.byd.assistant.activity.SettingsActivity +import com.kangrio.byd.assistant.data.ReleaseInfo +import com.kangrio.byd.assistant.util.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +object OtaUpdater { + private const val TAG = "OtaUpdater" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "Assistant-OTA-Updater") + .build() + chain.proceed(request) + } + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl("https://api.github.com/") + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val gitHubApi: GitHubApi = retrofit.create(GitHubApi::class.java) + + /** + * Checks whether an update check should be performed based on the 3-hour frequency interval. + */ + fun shouldCheck(force: Boolean = false): Boolean { + if (force) return true + val lastCheck = Preferences.lastOtaCheckTime + val currentTime = System.currentTimeMillis() + return (currentTime - lastCheck) >= Constant.OTA_CHECK_INTERVAL_MS + } + + /** + * Triggers a check in the background without blocking the caller. + */ + fun checkUpdateInBackground(context: Context, force: Boolean = false) { + if (!shouldCheck(force)) { + Log.d(TAG, "Skipping OTA check, throttled by 3-hour frequency.") + return + } + + scope.launch { + try { + val releaseInfo = checkForUpdate(context, force = force) + if (releaseInfo != null) { + Log.i(TAG, "New OTA version available: ${releaseInfo.tagName}") + Preferences.latestOtaVersion = releaseInfo.tagName + showUpdateNotification(context, releaseInfo) + } else { + Log.d(TAG, "Assistant is up-to-date.") + } + } catch (e: Throwable) { + Log.e(TAG, "Error checking OTA update in background", e) + } + } + } + + /** + * Performs a network check against the GitHub latest release API using Retrofit. + * Returns [com.kangrio.byd.assistant.data.ReleaseInfo] if a newer version is available, or null otherwise. + */ + suspend fun checkForUpdate(context: Context, force: Boolean = false): ReleaseInfo? = withContext(Dispatchers.IO) { + if (!shouldCheck(force)) { + return@withContext null + } + + try { + val release = gitHubApi.getLatestRelease(owner = "kangrio", repo = "Assistant") + Preferences.lastOtaCheckTime = System.currentTimeMillis() + + val apkAsset = release.assets.firstOrNull { asset -> + asset.name.endsWith(".apk", ignoreCase = true) + } ?: run { + Log.w(TAG, "No APK asset found in release ${release.tag_name}") + return@withContext null + } + + val currentVersionName = getCurrentVersionName(context) + val cleanRemoteVersion = release.tag_name.removePrefix("v").removePrefix("V") + + if (isNewerVersion(cleanRemoteVersion, currentVersionName)) { + return@withContext ReleaseInfo( + tagName = release.tag_name, + versionName = cleanRemoteVersion, + title = release.name ?: release.tag_name, + body = release.body ?: "", + htmlUrl = release.html_url, + downloadUrl = apkAsset.browser_download_url, + apkName = apkAsset.name, + size = apkAsset.size + ) + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to check for OTA update via Retrofit", e) + } + + return@withContext null + } + + /** + * Gets the current installed version name of the app. + */ + fun getCurrentVersionName(context: Context): String { + return try { + val pInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + pInfo.versionName ?: "1.0.0" + } catch (e: Exception) { + "1.0.0" + } + } + + /** + * Compares semantic versions (e.g. "1.0.3" > "1.0.2"). + */ + fun isNewerVersion(remote: String, current: String): Boolean { + val cleanRemote = remote.trim().removePrefix("v").removePrefix("V") + val cleanCurrent = current.trim().removePrefix("v").removePrefix("V") + + val remoteBase = cleanRemote.split("-")[0] + val currentBase = cleanCurrent.split("-")[0] + + val remoteParts = remoteBase.split(".").mapNotNull { it.toIntOrNull() } + val currentParts = currentBase.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(remoteParts.size, currentParts.size) + for (i in 0 until maxLength) { + val r = remoteParts.getOrElse(i) { 0 } + val c = currentParts.getOrElse(i) { 0 } + if (r > c) return true + if (r < c) return false + } + + // If base versions are equal, check pre-release suffix (release > beta) + if (cleanRemote.contains("-") && !cleanCurrent.contains("-")) { + return false + } + if (!cleanRemote.contains("-") && cleanCurrent.contains("-")) { + return true + } + + return false + } + + /** + * Creates notification channel for OTA update notifications. + */ + fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + Constant.OTA_NOTIFICATION_CHANNEL_ID, + "App Updates", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications for Assistant app updates" + } + val manager = context.getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(channel) + } + } + + /** + * Displays a system notification prompting the user about the new version. + */ + fun showUpdateNotification(context: Context, releaseInfo: ReleaseInfo) { + createNotificationChannel(context) + + val intent = Intent(context, SettingsActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("EXTRA_OTA_VERSION", releaseInfo.tagName) + putExtra("EXTRA_OTA_URL", releaseInfo.downloadUrl) + putExtra("EXTRA_OTA_NAME", releaseInfo.apkName) + putExtra("EXTRA_OTA_BODY", releaseInfo.body) + } + + val pendingIntent = PendingIntent.getActivity( + context, + Constant.OTA_NOTIFICATION_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, Constant.OTA_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher_round) + .setContentTitle("Update Available: ${releaseInfo.tagName}") + .setContentText("New version ${releaseInfo.tagName} is available. Tap to update.") + .setStyle(NotificationCompat.BigTextStyle().bigText("Version ${releaseInfo.tagName} is ready to install.\n\n${releaseInfo.body.take(200)}")) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.notify(Constant.OTA_NOTIFICATION_ID, notification) + } + + /** + * Downloads an APK file to the app's cache directory with progress reporting. + */ + suspend fun downloadApk( + context: Context, + downloadUrl: String, + fileName: String, + onProgress: (progress: Float) -> Unit = {} + ): File? = withContext(Dispatchers.IO) { + val downloadDir = File(context.cacheDir, "ota_updates").apply { mkdirs() } + val targetFile = File(downloadDir, fileName) + + if (targetFile.exists()) { + targetFile.delete() + } + + val request = Request.Builder() + .url(downloadUrl) + .header("User-Agent", "Assistant-OTA-Updater") + .get() + .build() + + try { + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.e(TAG, "Download failed with HTTP ${response.code}") + return@withContext null + } + + val body = response.body ?: return@withContext null + val totalBytes = body.contentLength() + var downloadedBytes = 0L + + body.byteStream().use { input -> + FileOutputStream(targetFile).use { output -> + val buffer = ByteArray(8 * 1024) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + if (totalBytes > 0) { + val progress = downloadedBytes.toFloat() / totalBytes.toFloat() + onProgress(progress) + } + } + output.flush() + } + } + onProgress(1.0f) + return@withContext targetFile + } + } catch (e: Throwable) { + Log.e(TAG, "Error downloading APK", e) + targetFile.delete() + return@withContext null + } + } + + /** + * Prompts system package installer to install the downloaded APK file. + */ + fun installApk(context: Context, apkFile: File) { + try { + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + + val installIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + context.startActivity(installIntent) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start APK installation", e) + } + } +} diff --git a/app/src/main/java/com/kangrio/byd/assistant/receiver/BootReceiver.kt b/app/src/main/java/com/kangrio/byd/assistant/receiver/BootReceiver.kt index b490eb2..245b8cb 100644 --- a/app/src/main/java/com/kangrio/byd/assistant/receiver/BootReceiver.kt +++ b/app/src/main/java/com/kangrio/byd/assistant/receiver/BootReceiver.kt @@ -4,12 +4,17 @@ import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.kangrio.byd.assistant.ota.OtaUpdater +import com.kangrio.byd.assistant.service.VoiceWakeService +import com.kangrio.byd.assistant.util.Preferences import com.kangrio.byd.assistant.util.Utils class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + OtaUpdater.checkUpdateInBackground(context, force = false) if (!Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS)) return - Utils.enableVoiceAssistant(context) + Utils.enableVoiceAssistant(context, Preferences.assistantPackageComponent) + VoiceWakeService.startService(context) } } \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/service/SnowboyDetector.kt b/app/src/main/java/com/kangrio/byd/assistant/service/SnowboyDetector.kt new file mode 100644 index 0000000..c280cb3 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/service/SnowboyDetector.kt @@ -0,0 +1,194 @@ +package com.kangrio.byd.assistant.service + +import android.annotation.SuppressLint +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import ai.kitt.snowboy.SnowboyDetect +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.atomic.AtomicBoolean + +@SuppressLint("MissingPermission") +class SnowboyDetector( + private val context: Context, + private val modelName: String = "hey_rio", + private val sensitivity: Float = 0.5f, + private val audioGain: Float = 1.0f, + private val onDetected: () -> Unit +) { + + private val modelsPath = "snowboy/models" + private var detector: SnowboyDetect? = null + private var audioRecord: AudioRecord? = null + + private val running = AtomicBoolean(false) + private val paused = AtomicBoolean(false) + + private var thread: Thread? = null + + init { + context.assets.list(modelsPath)?.forEach { name -> + val file = File( + context.filesDir, + "$modelsPath/$name" + ).apply { + parentFile?.mkdirs() + } + if (!file.exists()) { + context.assets.open("$modelsPath/$name").use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + } + } + } + + fun start() { + if (!running.compareAndSet(false, true)) return + + val sampleRate = 16000 + val channelConfig = AudioFormat.CHANNEL_IN_MONO + val audioFormat = AudioFormat.ENCODING_PCM_16BIT + + val bufferSize = AudioRecord.getMinBufferSize( + sampleRate, + channelConfig, + audioFormat + ) + + require(bufferSize > 0) { + "Unable to determine AudioRecord buffer size" + } + + val recordBufferSize = maxOf( + bufferSize, + sampleRate / 2 + ) + + detector = SnowboyDetect( + assetFile("snowboy/common.res"), + getModelPath(modelName) + ).apply { + SetSensitivity(sensitivity.toString()) + SetAudioGain(audioGain) + ApplyFrontend(true) + } + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + sampleRate, + channelConfig, + audioFormat, + recordBufferSize + ) + + audioRecord?.startRecording() + + thread = Thread { + detectionLoop(recordBufferSize / 2) + }.apply { + name = "SnowboyDetector" + start() + } + } + + fun pause() { + paused.set(true) + } + + fun resume() { + if (!running.get()) return + + paused.set(false) + } + + fun isPaused(): Boolean { + return paused.get() + } + + private fun detectionLoop(bufferSize: Int) { + val buffer = ShortArray(bufferSize) + + try { + while (running.get()) { + + val count = audioRecord?.read( + buffer, + 0, + buffer.size + ) ?: break + + if (count <= 0 || paused.get()) { + continue + } + + val result = detector?.RunDetection( + buffer, + count + ) ?: break + + if (result > 0) { + onDetected() + } + } + } finally { + stopInternal() + } + } + + fun stop() { + if (!running.getAndSet(false)) return + + thread?.interrupt() + + stopInternal() + } + + private fun stopInternal() { + try { + audioRecord?.stop() + } catch (_: Exception) { + } + + try { + audioRecord?.release() + } catch (_: Exception) { + } + + audioRecord = null + + detector?.delete() + detector = null + + thread = null + } + + private fun getModelPath(modelName: String): String { + return File( + context.filesDir, + "snowboy/models/$modelName.pmdl" + ).absolutePath + } + + private fun assetFile(path: String): String { + val file = File( + context.filesDir, + path + ).apply { + parentFile?.mkdirs() + } + + if (!file.exists()) { + context.assets.open(path).use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + } + + return file.absolutePath + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/service/VoiceWakeService.kt b/app/src/main/java/com/kangrio/byd/assistant/service/VoiceWakeService.kt new file mode 100644 index 0000000..6f95d69 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/service/VoiceWakeService.kt @@ -0,0 +1,271 @@ +package com.kangrio.byd.assistant.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.kangrio.byd.assistant.R +import com.kangrio.byd.assistant.StartActivity +import com.kangrio.byd.assistant.activity.SettingsActivity +import com.kangrio.byd.assistant.util.Preferences +import com.kangrio.byd.assistant.util.Utils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import android.content.BroadcastReceiver +import android.content.IntentFilter +import com.kangrio.byd.assistant.ota.OtaUpdater +import kotlinx.coroutines.launch + +class VoiceWakeService : Service() { + private var detector: SnowboyDetector? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val handler = Handler(Looper.getMainLooper()) + lateinit var toast: Toast + + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action == Intent.ACTION_SCREEN_ON) { + Log.d("VoiceWakeService", "Screen turned ON, checking for OTA update...") + OtaUpdater.checkUpdateInBackground(context, force = false) + } + } + } + + override fun onCreate() { + super.onCreate() + isStarted = true + createNotificationChannel() + + val filter = IntentFilter(Intent.ACTION_SCREEN_ON) + registerReceiver(screenReceiver, filter) + + OtaUpdater.checkUpdateInBackground(this, force = false) + + scope.launch { + startHotwordDetection() + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + when(action) { + START_HOTWORD_DETECTION -> { + scope.launch { + startHotwordDetection() + } + } + + STOP_HOTWORD_DETECTION -> { + stopHotwordDetection() + } + + SET_MODEL -> { + val filePath = intent.extras?.getString("pmdlFile") ?: return START_STICKY + setModel(filePath) + } + + SET_SENSITIVITY -> { + val sensitivity = intent.extras?.getFloat("sensitivity") ?: return START_STICKY + setSensitivity(sensitivity) + } + + SET_GAIN -> { + val gain = intent.extras?.getFloat("gain") ?: return START_STICKY + setGain(gain) + } + } + + return START_STICKY + } + + fun showToast(message: String) { + handler.post { + if (::toast.isInitialized) { + toast.cancel() + } + toast = Toast.makeText(this, message, Toast.LENGTH_SHORT) + toast.show() + } + } + + fun setSensitivity(sensitivity: Float) { + Preferences.hotwordSensitivity = sensitivity + stopHotwordDetection() + scope.launch { + startHotwordDetection() + } + } + + fun setGain(gain: Float) { + Preferences.hotwordGain = gain + stopHotwordDetection() + scope.launch { + startHotwordDetection() + } + } + + fun setModel(modelName: String) { + stopHotwordDetection() + Preferences.hotwordModelName = modelName + scope.launch { + startHotwordDetection() + } + } + + fun startHotwordDetection() { + if (!Utils.setupCompleted(this@VoiceWakeService) || !Preferences.startHotword) return + + val modelName = Preferences.hotwordModelName + detector?.stop() + detector = SnowboyDetector( + context = this@VoiceWakeService, + modelName = modelName, + sensitivity = Preferences.hotwordSensitivity, + audioGain = Preferences.hotwordGain, + ) { + onHeyRioDetected() + } + + detector?.start() + showToast("""Hotword Detection Started""") + } + + fun stopHotwordDetection() { + detector?.stop() + detector = null + showToast("""Hotword Detection Stopped""") + } + + private fun onHeyRioDetected() { + scope.launch(Dispatchers.Main) { + Utils.startVoiceAssistant(this@VoiceWakeService) + } + } + + override fun onDestroy() { + isStarted = false + try { + unregisterReceiver(screenReceiver) + } catch (_: Exception) {} + stopHotwordDetection() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + private fun createNotificationChannel() { + val manager = getSystemService( + NotificationManager::class.java + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Voice Assistant", + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + + handler.post(object : Runnable { + override fun run() { + try { + startForeground(NOTIFICATION_ID, createNotification()) + } catch (e: Throwable) { + Log.e("VoiceWakeService", "Error creating notification", e) + handler.postDelayed(this, 1000) + } + } + }) + } + + private fun createNotification(): Notification { + val intent = Intent(this, SettingsActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Voice Assistant") + .setContentText("Click to open settings") + .setSmallIcon(R.mipmap.ic_launcher_round) + .setOngoing(true) + .setContentIntent(pendingIntent) + .build() + } + + companion object { + private const val CHANNEL_ID = "voice_assistant" + private const val NOTIFICATION_ID = 1001 + private var isStarted = false + + const val START_HOTWORD_DETECTION = + "com.kangrio.byd.assistant.action.START_HOTWORD_DETECTION" + const val STOP_HOTWORD_DETECTION = "com.kangrio.byd.assistant.action.STOP_HOTWORD_DETECTION" + const val SET_MODEL = "com.kangrio.byd.assistant.action.SET_MODEL" + const val SET_SENSITIVITY = "com.kangrio.byd.assistant.action.SET_SENSITIVITY" + const val SET_GAIN = "com.kangrio.byd.assistant.action.SET_GAIN" + + fun startService(context: Context) { + if (isStarted || !Utils.setupCompleted(context)) return + + val intent = Intent(context, VoiceWakeService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun setState(context: Context, state: Boolean) { + Preferences.startHotword = state + val intent = Intent(context, VoiceWakeService::class.java).apply { + action = if (state) START_HOTWORD_DETECTION else STOP_HOTWORD_DETECTION + } + context.startService(intent) + } + + fun setModel(context: Context, pmdlFilePath: String) { + val intent = Intent(context, VoiceWakeService::class.java).apply { + action = SET_MODEL + putExtra("pmdlFile", pmdlFilePath) + } + context.startService(intent) + } + + fun setSensitivity(context: Context, sensitivity: Float) { + val intent = Intent(context, VoiceWakeService::class.java).apply { + action = SET_SENSITIVITY + putExtra("sensitivity", sensitivity) + } + context.startService(intent) + } + + fun setGain(context: Context, gain: Float) { + val intent = Intent(context, VoiceWakeService::class.java).apply { + action = SET_GAIN + putExtra("gain", gain) + } + context.startService(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/ui/composable/AppIcon.kt b/app/src/main/java/com/kangrio/byd/assistant/ui/composable/AppIcon.kt new file mode 100644 index 0000000..45688d6 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/ui/composable/AppIcon.kt @@ -0,0 +1,42 @@ +package com.kangrio.byd.assistant.ui.composable + +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.createBitmap + +@Composable +fun AppIcon( + packageName: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + val bitmap = remember(packageName) { + runCatching { + val drawable = context.packageManager.getApplicationIcon(packageName) + + val width = drawable.intrinsicWidth.coerceAtLeast(1) + val height = drawable.intrinsicHeight.coerceAtLeast(1) + + createBitmap(width, height).also { bitmap -> + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + } + }.getOrNull() + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = null, + modifier = modifier + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/ui/onboarding/PermissionOnboardingScreen.kt b/app/src/main/java/com/kangrio/byd/assistant/ui/onboarding/PermissionOnboardingScreen.kt new file mode 100644 index 0000000..3834345 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/ui/onboarding/PermissionOnboardingScreen.kt @@ -0,0 +1,922 @@ +package com.kangrio.byd.assistant.ui.onboarding + +import android.Manifest +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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.core.net.toUri +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.kangrio.byd.assistant.service.VoiceWakeService +import com.kangrio.byd.assistant.ui.composable.AppIcon +import com.kangrio.byd.assistant.util.Preferences +import com.kangrio.byd.assistant.util.Utils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +/** Data model representing a permission item in onboarding */ +data class OnboardingPermissionItem( + val id: String, + val title: String, + val category: String, + val description: String, + val detailedReason: String, + val icon: ImageVector, + val isGranted: Boolean, + val isRequired: Boolean = true, + val manualAdbCommand: String? = null, + val actionText: String, + val onAction: () -> Unit +) + +enum class OnboardingTab { + WIZARD, + CHECKLIST +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionOnboardingScreen( + onOnboardingFinished: () -> Unit = {} +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + + var selectedTab by remember { mutableStateOf(OnboardingTab.WIZARD) } + var currentStepIndex by remember { mutableIntStateOf(0) } + var isAdbProcessing by remember { mutableStateOf(false) } + var adbMessage by remember { mutableStateOf("") } + var showAssistantDialog by remember { mutableStateOf(false) } + + // Live permission states + var isMicGranted by remember { mutableStateOf(Utils.isGranted(context, Manifest.permission.RECORD_AUDIO)) } + var isOverlayGranted by remember { mutableStateOf(Settings.canDrawOverlays(context)) } + var isAutoStartGranted by remember { mutableStateOf(Utils.isGrantedAutoStart(context)) } + var isWriteSecureSettingsGranted by remember { mutableStateOf(Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS)) } + var isNotificationGranted by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Utils.isGranted(context, Manifest.permission.POST_NOTIFICATIONS) + } else true + ) + } + var isAssistantConfigured by remember { + mutableStateOf(Utils.isEnabledVoiceAssistant(context) && Preferences.assistantPackageComponent.isNotEmpty()) + } + var assistantApps by remember { mutableStateOf(Utils.listAssistantPackages(context)) } + var selectedAssistant by remember { mutableStateOf(Utils.getCurrentAssistantApp(context)) } + + // Launchers for permissions + val micPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + isMicGranted = granted + if (!granted) { + Toast.makeText(context, "Microphone permission is required for voice commands", Toast.LENGTH_SHORT).show() + } + } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + isNotificationGranted = granted + } + + // Auto-update permission states every 1 sec or on resume + LaunchedEffect(lifecycleOwner) { + while (true) { + isMicGranted = Utils.isGranted(context, Manifest.permission.RECORD_AUDIO) + isOverlayGranted = Settings.canDrawOverlays(context) + isAutoStartGranted = Utils.isGrantedAutoStart(context) + isWriteSecureSettingsGranted = Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + isNotificationGranted = Utils.isGranted(context, Manifest.permission.POST_NOTIFICATIONS) + } + isAssistantConfigured = Utils.isEnabledVoiceAssistant(context) && Preferences.assistantPackageComponent.isNotEmpty() + assistantApps = Utils.listAssistantPackages(context) + selectedAssistant = Utils.getCurrentAssistantApp(context) + + if (Utils.setupCompleted(context)) { + VoiceWakeService.startService(context) + } + delay(1_000L.milliseconds) + } + } + + val permissionItems = remember( + isMicGranted, + isOverlayGranted, + isAutoStartGranted, + isWriteSecureSettingsGranted, + isNotificationGranted, + isAssistantConfigured, + selectedAssistant + ) { + val list = mutableListOf( + OnboardingPermissionItem( + id = "mic", + title = "Microphone Access", + category = "Core Voice Detection", + description = "Allows Assistant to capture wake word detection and process speech commands.", + detailedReason = "Without microphone access, hotword detection ('Hey Rio') and voice command recognition cannot operate.", + icon = Icons.Default.Mic, + isGranted = isMicGranted, + isRequired = true, + actionText = if (isMicGranted) "Granted" else "Grant Microphone Access", + onAction = { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ), + OnboardingPermissionItem( + id = "overlay", + title = "Display Over Other Apps", + category = "System UI Floating Overlay", + description = "Allows the Assistant to open the Voice Assistant over other apps.", + detailedReason = "This permission is required for the Assistant to launch the Voice Assistant while you are using other apps.", + icon = Icons.Default.Layers, + isGranted = isOverlayGranted, + isRequired = true, + actionText = if (isOverlayGranted) "Granted" else "Open Overlay Settings", + onAction = { + context.startActivity( + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + "package:${context.packageName}".toUri() + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + ), + OnboardingPermissionItem( + id = "autostart", + title = "Auto-Start & Background Execution", + category = "Background Service", + description = "Keeps the background listener active on startup without OS battery optimization kills.", + detailedReason = "Ensures voice detection service automatically starts when device reboots or remains in standby.", + icon = Icons.Default.BatteryChargingFull, + isGranted = isAutoStartGranted, + isRequired = true, + actionText = if (isAutoStartGranted) "Configured" else "Allow Auto-Start", + onAction = { + scope.launch { + Utils.openAutoStartSettings(context) + delay(2000.milliseconds) + Utils.markAutoStartTime(context) + } + } + ), + OnboardingPermissionItem( + id = "securesettings", + title = "Write Secure Settings", + category = "System Voice Assistant Key", + description = "Registers Assistant as your system-level default voice interaction service.", + detailedReason = "Android requires WRITE_SECURE_SETTINGS permission to set system voice keys. Grantable automatically via local ADB socket or command.", + icon = Icons.Default.Security, + isGranted = isWriteSecureSettingsGranted, + isRequired = true, + manualAdbCommand = "adb shell pm grant ${context.packageName} ${Manifest.permission.WRITE_SECURE_SETTINGS}", + actionText = if (isWriteSecureSettingsGranted) "Permission Granted" else "Grant via Local ADB", + onAction = { + scope.launch { + isAdbProcessing = true + adbMessage = "Connecting to device via local ADB socket to grant permission..." + Utils.adbRequestPermission(context, Manifest.permission.WRITE_SECURE_SETTINGS) + delay(1200) + isAdbProcessing = false + isWriteSecureSettingsGranted = Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) + } + } + ), + OnboardingPermissionItem( + id = "assistant_app", + title = "Target AI Assistant App", + category = "Voice Engine Selection", + description = "Select which AI voice application to trigger when Assistant is invoked.", + detailedReason = "Choose between Google, ChatGPT, Perplexity, Claude or other installed voice engines.", + icon = Icons.Default.SmartToy, + isGranted = isAssistantConfigured, + isRequired = true, + actionText = if (selectedAssistant.name.isNotEmpty()) "Selected: ${selectedAssistant.name}" else "Select Assistant App", + onAction = { + if (assistantApps.isNotEmpty()) { + if (!isWriteSecureSettingsGranted) { + Toast.makeText(context, "Write Secure Settings permission is required", Toast.LENGTH_SHORT).show() + return@OnboardingPermissionItem + } + showAssistantDialog = true + } else { + Utils.openStore(context) + } + } + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + list.add( + 0, + OnboardingPermissionItem( + id = "notification", + title = "Post Notifications", + category = "Service Status Notification", + description = "Displays the background voice detection active service icon.", + detailedReason = "Android 13+ requires notification permission for foreground services with microphone access.", + icon = Icons.Default.Notifications, + isGranted = isNotificationGranted, + isRequired = false, + actionText = if (isNotificationGranted) "Granted" else "Allow Notifications", + onAction = { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + ) + ) + } + + list + } + + val totalPermissions = permissionItems.size + val grantedCount = permissionItems.count { it.isGranted } + val progress = if (totalPermissions > 0) grantedCount.toFloat() / totalPermissions.toFloat() else 0f + val isAllGranted = grantedCount == totalPermissions + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = "Permission Onboarding", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "$grantedCount of $totalPermissions Granted", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + IconButton(onClick = { + isMicGranted = Utils.isGranted(context, Manifest.permission.RECORD_AUDIO) + isOverlayGranted = Settings.canDrawOverlays(context) + isAutoStartGranted = Utils.isGrantedAutoStart(context) + isWriteSecureSettingsGranted = Utils.isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) + }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh status") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + // Overall Progress Bar + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + color = if (isAllGranted) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + // Tab Selector (Step Wizard vs Full Checklist) + TabRow( + selectedTabIndex = selectedTab.ordinal, + containerColor = MaterialTheme.colorScheme.surface + ) { + Tab( + selected = selectedTab == OnboardingTab.WIZARD, + onClick = { selectedTab = OnboardingTab.WIZARD }, + text = { Text("Step-by-Step") } + ) + Tab( + selected = selectedTab == OnboardingTab.CHECKLIST, + onClick = { selectedTab = OnboardingTab.CHECKLIST }, + text = { Text("All Permissions") } + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Crossfade(targetState = selectedTab, label = "tabTransition") { tab -> + when (tab) { + OnboardingTab.WIZARD -> { + WizardView( + items = permissionItems, + currentStepIndex = currentStepIndex, + onStepChange = { currentStepIndex = it }, + isAllGranted = isAllGranted, + onFinish = { + if (Utils.setupCompleted(context)) { + VoiceWakeService.startService(context) + } + onOnboardingFinished() + } + ) + } + OnboardingTab.CHECKLIST -> { + ChecklistView( + items = permissionItems, + isAllGranted = isAllGranted, + onFinish = { + if (Utils.setupCompleted(context)) { + VoiceWakeService.startService(context) + } + onOnboardingFinished() + } + ) + } + } + } + } + } + } + + // ADB Processing Modal + if (isAdbProcessing) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + text = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = adbMessage, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + ) + } + + // Assistant App Select Dialog + if (showAssistantDialog) { + AlertDialog( + onDismissRequest = { showAssistantDialog = false }, + title = { Text("Select Target Assistant") }, + text = { + Column { + assistantApps.forEach { assistant -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + Preferences.assistantPackageComponent = assistant.componentName + Utils.enableVoiceAssistant(context, assistant.componentName) + isAssistantConfigured = Utils.isEnabledVoiceAssistant(context) + showAssistantDialog = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon( + packageName = assistant.packageName, + modifier = Modifier.size(40.dp) + ) + Spacer(Modifier.width(16.dp)) + Text( + text = assistant.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + } + } + }, + confirmButton = {} + ) + } +} + +@Composable +fun WizardView( + items: List, + currentStepIndex: Int, + onStepChange: (Int) -> Unit, + isAllGranted: Boolean, + onFinish: () -> Unit +) { + if (items.isEmpty()) return + + val validIndex = currentStepIndex.coerceIn(0, items.size - 1) + val item = items[validIndex] + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + // Step Badge + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Text( + text = "Step ${validIndex + 1} of ${items.size}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold + ) + } + + // Big Icon Banner + Surface( + color = if (item.isGranted) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = CircleShape, + modifier = Modifier + .size(96.dp) + .padding(8.dp), + border = BorderStroke( + 2.dp, + if (item.isGranted) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (item.isGranted) Icons.Default.CheckCircle else item.icon, + contentDescription = item.title, + tint = if (item.isGranted) Color(0xFF2E7D32) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Category & Title + Text( + text = item.category.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 1.sp, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = item.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + + // Status Chip + StatusChip(isGranted = item.isGranted, isRequired = item.isRequired) + + Spacer(modifier = Modifier.height(20.dp)) + + // Description Card + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = item.description, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.detailedReason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (item.manualAdbCommand != null && !item.isGranted) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Manual ADB Command:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = item.manualAdbCommand, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("ADB Command", item.manualAdbCommand) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied ADB command to clipboard", Toast.LENGTH_SHORT).show() + }) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy command", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Action Button + Button( + onClick = item.onAction, + enabled = !item.isGranted || item.id == "assistant_app", + colors = ButtonDefaults.buttonColors( + containerColor = if (item.isGranted) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (item.isGranted) { + Icon(Icons.Default.Check, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = item.actionText, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Navigation Footer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { onStepChange(validIndex - 1) }, + enabled = validIndex > 0 + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Previous") + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + + if (validIndex < items.size - 1) { + Button( + onClick = { onStepChange(validIndex + 1) } + ) { + Text("Next") + Spacer(modifier = Modifier.width(4.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Next") + } + } else { + Button( + enabled = isAllGranted, + onClick = onFinish, + colors = ButtonDefaults.buttonColors( + containerColor = if (isAllGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary + ) + ) { + Text("Launch") + Spacer(modifier = Modifier.width(4.dp)) + Icon(Icons.Default.PlayArrow, contentDescription = "Launch") + } + } + } + } +} + +@Composable +fun ChecklistView( + items: List, + isAllGranted: Boolean, + onFinish: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + items.forEach { item -> + PermissionCard(item = item) + Spacer(modifier = Modifier.height(12.dp)) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + enabled = isAllGranted, + onClick = onFinish, + colors = ButtonDefaults.buttonColors( + containerColor = if (isAllGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = if (isAllGranted) Icons.Default.CheckCircle else Icons.Default.PlayArrow, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Launch", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +fun PermissionCard(item: OnboardingPermissionItem) { + val context = LocalContext.current + val borderColor = if (item.isGranted) Color(0xFF81C784) else MaterialTheme.colorScheme.outlineVariant + + Card( + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, borderColor), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Surface( + color = if (item.isGranted) Color(0xFFC8E6C9) else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = CircleShape, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = if (item.isGranted) Color(0xFF2E7D32) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = item.category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + StatusChip(isGranted = item.isGranted, isRequired = item.isRequired) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = item.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (item.manualAdbCommand != null && !item.isGranted) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = item.manualAdbCommand, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("ADB Command", item.manualAdbCommand) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied ADB command", Toast.LENGTH_SHORT).show() + }) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy command", + modifier = Modifier.size(18.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + FilledTonalButton( + onClick = item.onAction, + enabled = !item.isGranted || item.id == "assistant_app", + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text(text = item.actionText) + } + } + } +} + +@Composable +fun StatusChip(isGranted: Boolean, isRequired: Boolean) { + val backgroundColor = when { + isGranted -> Color(0xFFE8F5E9) + isRequired -> Color(0xFFFFEBEE) + else -> Color(0xFFFFF8E1) + } + val textColor = when { + isGranted -> Color(0xFF2E7D32) + isRequired -> Color(0xFFC62828) + else -> Color(0xFFF57F17) + } + val text = when { + isGranted -> "Granted" + isRequired -> "Required" + else -> "Optional" + } + + Surface( + color = backgroundColor, + shape = CircleShape + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isGranted) Icons.Default.Check else Icons.Default.Warning, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/app/src/main/java/com/kangrio/byd/assistant/util/Preferences.kt b/app/src/main/java/com/kangrio/byd/assistant/util/Preferences.kt new file mode 100644 index 0000000..ec7ce14 --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/util/Preferences.kt @@ -0,0 +1,45 @@ +package com.kangrio.byd.assistant.util + +import android.content.Context +import android.content.SharedPreferences +import com.kangrio.byd.assistant.Constant +import androidx.core.content.edit + +object Preferences { + lateinit var prefs: SharedPreferences + fun init(context: Context) { + prefs = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) + } + + var assistantPackageComponent: String + get() = prefs.getString(Constant.PREFS_ASSISTANT_PACKAGE_COMPONENT, "") ?: "" + set(value) = prefs.edit { putString(Constant.PREFS_ASSISTANT_PACKAGE_COMPONENT, value) } + + var startHotword: Boolean + get() = prefs.getBoolean(Constant.PREFS_START_HOTWORD, true) + set(value) = prefs.edit { putBoolean(Constant.PREFS_START_HOTWORD, value) } + + var hotwordModelName: String + get() = prefs.getString(Constant.PREFS_HOTWORD_MODEL_NAME, "hey_rio") ?: "hey_rio" + set(value) = prefs.edit { putString(Constant.PREFS_HOTWORD_MODEL_NAME, value) } + + var hotwordSensitivity: Float + get() = prefs.getFloat(Constant.PREFS_HOTWORD_SENSITIVITY, 0.5f) + set(value) = prefs.edit { putFloat(Constant.PREFS_HOTWORD_SENSITIVITY, value) } + + var hotwordGain: Float + get() = prefs.getFloat(Constant.PREFS_HOTWORD_GAIN, 1.0f) + set(value) = prefs.edit { putFloat(Constant.PREFS_HOTWORD_GAIN, value) } + + var playDingOnStart: Boolean + get() = prefs.getBoolean(Constant.PREFS_PLAY_DING_ON_START, true) + set(value) = prefs.edit { putBoolean(Constant.PREFS_PLAY_DING_ON_START, value) } + + var lastOtaCheckTime: Long + get() = prefs.getLong(Constant.PREFS_LAST_OTA_CHECK_TIME, 0L) + set(value) = prefs.edit { putLong(Constant.PREFS_LAST_OTA_CHECK_TIME, value) } + + var latestOtaVersion: String + get() = prefs.getString(Constant.PREFS_LATEST_OTA_VERSION, "") ?: "" + set(value) = prefs.edit { putString(Constant.PREFS_LATEST_OTA_VERSION, value) } +} \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/util/SnowboyTrainClient.kt b/app/src/main/java/com/kangrio/byd/assistant/util/SnowboyTrainClient.kt new file mode 100644 index 0000000..45bddbe --- /dev/null +++ b/app/src/main/java/com/kangrio/byd/assistant/util/SnowboyTrainClient.kt @@ -0,0 +1,97 @@ +package com.kangrio.byd.assistant.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Client for the snowboy-seasalt HTTP API (https://github.com/rhasspy/snowboy-seasalt). + * + * POSTs 3+ recorded wake-word .wav samples to a self-hosted `snowboy-seasalt` + * Docker instance's `/generate` endpoint and saves the returned `.pmdl` model. + * + * Note: the seasalt server has no auth/TLS of its own, so `baseUrl` should point + * at a host on your local network / VPN, not a publicly exposed address. + */ +class SnowboyTrainClient( + private val baseUrl: String, // e.g. "http://192.168.1.50:8000" + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) // ffmpeg trim + model generation isn't instant + .build() +) { + sealed class TrainResult { + data class Success(val pmdlFile: File) : TrainResult() + data class Failure(val message: String) : TrainResult() + } + + /** + * @param modelName name for the resulting wake word (also used as output filename) + * @param samples at least 3 recorded .wav examples of the wake word + * @param outputDir directory to save the returned .pmdl into + * @param noTrim disable the server's automatic leading/trailing silence trim + */ + suspend fun train( + modelName: String, + samples: List, + outputDir: File, + noTrim: Boolean = false + ): TrainResult = withContext(Dispatchers.IO) { + require(samples.size >= 3) { "Snowboy requires at least 3 audio examples" } + try { + val bodyBuilder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("modelName", modelName) + + samples.forEachIndexed { index, file -> + bodyBuilder.addFormDataPart( + "example${index + 1}", + file.name, + file.asRequestBody("audio/wav".toMediaType()) + ) + } + + val url = buildString { + append(baseUrl.trimEnd('/')) + append("/generate") + if (noTrim) append("?noTrim=true") + } + + val request = Request.Builder() + .url(url) + .post(bodyBuilder.build()) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext TrainResult.Failure( + "Server returned ${response.code}: ${response.message}" + ) + } + + val body = response.body + ?: return@withContext TrainResult.Failure("Empty response body") + + if (!outputDir.exists()) outputDir.mkdirs() + val pmdlFile = File(outputDir, "$modelName.pmdl") + + pmdlFile.outputStream().use { out -> + body.byteStream().copyTo(out) + } + + TrainResult.Success(pmdlFile) + } + } catch (e: IOException) { + TrainResult.Failure(e.message ?: "Network error while training") + } + } +} + \ No newline at end of file diff --git a/app/src/main/java/com/kangrio/byd/assistant/util/Utils.kt b/app/src/main/java/com/kangrio/byd/assistant/util/Utils.kt index d110d85..e5f4138 100644 --- a/app/src/main/java/com/kangrio/byd/assistant/util/Utils.kt +++ b/app/src/main/java/com/kangrio/byd/assistant/util/Utils.kt @@ -2,14 +2,17 @@ package com.kangrio.byd.assistant.util import android.Manifest import android.annotation.SuppressLint +import android.app.Activity +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.media.SoundPool import android.os.Build import android.os.PowerManager import android.provider.Settings +import android.service.voice.VoiceInteractionService import android.util.Log -import android.widget.Toast import dadb.Dadb import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -19,68 +22,182 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Duration.Companion.milliseconds import androidx.core.content.edit import androidx.core.net.toUri +import com.kangrio.byd.assistant.Constant +import com.kangrio.byd.assistant.R +import com.kangrio.byd.assistant.StartActivity +import com.kangrio.byd.assistant.data.AssistantApp object Utils { + private val soundPool = SoundPool.Builder() + .setMaxStreams(1) + .build() + + private var dingSound: Int = -1 + fun isGranted(context: Context, permission: String): Boolean { return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } - fun enableVoiceAssistant(context: Context) { - val componentName = - "com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService" + fun enableVoiceAssistant(context: Context, componentName: String? = null) { + val componentName = componentName ?: return + putSecureSetting(context, "assistant", null) putSecureSetting(context, "voice_interaction_service", null) - Thread.sleep(100) - putSecureSetting(context, "assistant", componentName) putSecureSetting(context, "voice_interaction_service", componentName) } - fun isEnableVoiceAssistant(context: Context): Boolean { - val componentName = - "com.google.android.googlequicksearchbox/com.google.android.voiceinteraction.GsaVoiceInteractionService" + fun listAssistantPackages(context: Context): List { + val pm = context.packageManager + + return pm.queryIntentServices( + Intent(VoiceInteractionService.SERVICE_INTERFACE), + 0 + ).mapNotNull { resolveInfo -> + val serviceInfo = resolveInfo.serviceInfo ?: return@mapNotNull null + + AssistantApp( + name = serviceInfo.loadLabel(pm).toString(), + packageName = serviceInfo.packageName, + className = serviceInfo.name, + ) + } + } + + fun isSupportAssistService(context: Context, packageName: String): Boolean { + val pm = context.packageManager + val intent = Intent(VoiceInteractionService.SERVICE_INTERFACE) + intent.setPackage(packageName) + return pm.queryIntentServices(intent, 0).isNotEmpty() + } + + fun getCurrentAssistantApp(context: Context): AssistantApp { + val assistant = Settings.Secure.getString(context.contentResolver, "assistant") ?: return AssistantApp() + + val component = ComponentName.unflattenFromString(assistant) ?: return AssistantApp() + val appInfo = context.packageManager.getPackageInfo(component.packageName, 0) + val appLabel = appInfo.applicationInfo?.loadLabel(context.packageManager)?.toString() + + val assistantApp = AssistantApp( + name = appLabel ?: "", + packageName = component.packageName, + className = component.className, + ) + + return assistantApp + } + + fun isEnabledVoiceAssistant(context: Context): Boolean { val assistant = Settings.Secure.getString(context.contentResolver, "assistant") val voiceInteractionService = Settings.Secure.getString(context.contentResolver, "voice_interaction_service") - return assistant == componentName - && voiceInteractionService == componentName + return !assistant.isNullOrEmpty() && !voiceInteractionService.isNullOrEmpty() } fun setupCompleted(context: Context): Boolean { - val isGranted = isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) - val isEnableVoiceAssistant = isEnableVoiceAssistant(context) - val isGoogleAppInstalled = isGoogleAppInstalled(context) + val isGrantedWriteSecureSettings = isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS) + val isGrantedVoicePermission = isGranted(context, Manifest.permission.RECORD_AUDIO) + val isEnableVoiceAssistant = isEnabledVoiceAssistant(context) + val isAssistantAppsInstalled = listAssistantPackages(context).isNotEmpty() val isAutoStart = isGrantedAutoStart(context) - return isGranted && isEnableVoiceAssistant && isGoogleAppInstalled && isAutoStart + return isGrantedWriteSecureSettings && isGrantedVoicePermission && isEnableVoiceAssistant && isAssistantAppsInstalled && isAutoStart + } + + private fun playDing(context: Context) { + if (!Preferences.playDingOnStart) return + + if (dingSound == -1) { + dingSound = soundPool.load(context, R.raw.ding, 1) + soundPool.setOnLoadCompleteListener { pool, i, i1 -> + soundPool.play(dingSound, 1f, 1f, 0, 0, 1f) + } + } else { + soundPool.play(dingSound, 1f, 1f, 0, 0, 1f) + } } fun startVoiceAssistant(context: Context) { try { - val intent = Intent(Intent.ACTION_VOICE_COMMAND) - intent.setPackage("com.google.android.googlequicksearchbox") + if (context !is Activity) { + context.startActivity( + Intent(context, StartActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + return + } + + val assistantApp = getCurrentAssistantApp(context) + val packageName = assistantApp.packageName + val intent = Intent(Intent.ACTION_ASSIST).apply { + if (packageName.isNotEmpty()) { + setPackage(packageName) + } + } + + when (packageName) { + Constant.GOOGLE_APP_PACKAGE -> { + intent.action = Intent.ACTION_VOICE_COMMAND + } + + Constant.CHATGPT_APP_PACKAGE -> { + intent.component = ComponentName(packageName, Constant.CHATGPT_APP_ASSISTANT_CLASS_NAME) + intent.putExtra("isAssistant", true) + } + } + context.startActivity(intent) + playDing(context) } catch (e: Throwable) { - Toast.makeText(context, "${e.message}", Toast.LENGTH_LONG).show() + Log.e("Utils", "startVoiceAssistant", e) } } + fun getNotificationListenerComponentName(context: Context, packageName: String): ComponentName? { + val intent = Intent("android.service.notification.NotificationListenerService").apply { + setPackage(packageName) + } + val resolveInfos = context.packageManager.queryIntentServices(intent, 0) + if (resolveInfos.isEmpty()) return null + val serviceInfo = resolveInfos[0].serviceInfo + return ComponentName(serviceInfo.packageName, serviceInfo.name) + } + + fun isNotificationListenerEnabled(context: Context, packageName: String): Boolean { + val notificationListeners = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") + val components = notificationListeners?.split(":") ?: emptyList() + return components.any { ComponentName.unflattenFromString(it)?.packageName == packageName } + } + + suspend fun grantNotificationListener(context: Context, packageName: String): Boolean = + withContext(Dispatchers.IO) { + if (!isGranted(context, Manifest.permission.WRITE_SECURE_SETTINGS)) return@withContext false + + val componentName = + getNotificationListenerComponentName(context, packageName)?.flattenToString() + ?: return@withContext false + + adbShell(context, "cmd notification allow_listener $componentName") + return@withContext isNotificationListenerEnabled(context, packageName) + } + private fun putSecureSetting(context: Context, key: String, value: String?) { Settings.Secure.putString(context.contentResolver, key, value) } - suspend fun adbRequestPermission(context: Context, permission: String) = + suspend fun adbShell(context: Context, cmd: String) = withContext(Dispatchers.IO) { setupUserHome(context) withTimeoutOrNull(10_000L.milliseconds) { - val dAdb = Dadb.discover(connectTimeout = 5000, socketTimeout = 5000) ?: run { - Log.e("PermissionUtil", "No device found") - return@withTimeoutOrNull null - } while (isActive) { try { - dAdb.shell("pm grant ${context.packageName} $permission") + val dAdb = Dadb.discover(connectTimeout = 5000, socketTimeout = 5000) ?: run { + Log.e("Utils", "No device found") + return@withTimeoutOrNull null + } + dAdb.shell(cmd) return@withTimeoutOrNull null } catch (e: Throwable) { delay(100L.milliseconds) @@ -89,6 +206,12 @@ object Utils { } } + suspend fun adbRequestPermission(context: Context, permission: String) = + withContext(Dispatchers.IO) { + setupUserHome(context) + adbShell(context, "pm grant ${context.packageName} $permission") + } + private fun setupUserHome(context: Context) { val userHome = System.getProperty("user.home") ?: "" if (userHome.isEmpty()) { @@ -97,29 +220,18 @@ object Utils { } } - fun isGoogleAppInstalled(context: Context): Boolean { - val packageName = "com.google.android.googlequicksearchbox" - return try { - context.packageManager.getPackageInfo(packageName, 0) - true - } catch (e: PackageManager.NameNotFoundException) { - false - } - } - - fun openGoogleAppInStore(context: Context) { - val packageName = "com.google.android.googlequicksearchbox" + fun openStore(context: Context) { try { val intent = Intent( Intent.ACTION_VIEW, - android.net.Uri.parse("market://details?id=$packageName") + "market://".toUri() ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } catch (e: Throwable) { val intent = Intent( Intent.ACTION_VIEW, - android.net.Uri.parse("https://play.google.com/store/apps/details?id=$packageName") + "https://play.google.com".toUri() ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) diff --git a/app/src/main/jniLibs/arm64-v8a/libsnowboy-detect-android.so b/app/src/main/jniLibs/arm64-v8a/libsnowboy-detect-android.so new file mode 100644 index 0000000..dd18316 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libsnowboy-detect-android.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libsnowboy-detect-android.so b/app/src/main/jniLibs/armeabi-v7a/libsnowboy-detect-android.so new file mode 100644 index 0000000..be8da3a Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libsnowboy-detect-android.so differ diff --git a/app/src/main/res/raw/ding.mp3 b/app/src/main/res/raw/ding.mp3 new file mode 100644 index 0000000..b802072 Binary files /dev/null and b/app/src/main/res/raw/ding.mp3 differ diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..14beefd --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 15d0def..43aa7ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanistDrawablepainter = "0.37.3" agp = "9.3.1" coreKtx = "1.19.0" junit = "4.13.2" @@ -10,8 +11,11 @@ kotlin = "2.2.10" composeBom = "2025.12.00" materialIconsExtended = "1.7.8" dadb = "2.0.0" +okhttp = "5.4.0" +retrofit = "3.0.0" [libraries] +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -29,6 +33,9 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } dadb = { module = "dev.mobile:dadb", version.ref = "dadb" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }