commit 8cf0165f18ece43dfdf0ce6da9042478c54fc9e8 truex <truex@equwal.com> 2026-09-20 15:25:50 -0700 Build the full key remapper: channels, gesture engine, UI Replaces the single-purpose long-press-power prototype with the real app. Android offers no single way to intercept hardware keys, so the app is organised around capture channels - independent routes by which a press can be made to arrive here, each separately switchable: accessibility AI key and both volume keys, via onKeyEvent assistant power hold, by holding ROLE_ASSISTANT camera power double press, by being the default camera wallet power double press, by holding ROLE_WALLET viwoods the firmware's own Settings.System key bindings Each channel's manifest components start disabled and are flipped with setComponentEnabledSetting, so the app does not appear as an assistant, camera or wallet candidate until that box is ticked. Power is not like the other keys: PhoneWindowManager consumes it before the input dispatcher, so no filter can ever see it. It gets exactly three slots - short press (firmware setting only), double press and hold - and can never take part in a combination. Verified on this device that power_button_long_press is already 5 (LONG_PRESS_POWER_ASSISTANT) with the assistant role unheld, so the hold route works with no secure-settings change at all. The Viwoods setting names and token vocabulary were read out of setting_se08 1.3.7: CustomAiKey holds a flat component, while CustomVolumeUpKey / CustomVolumeDownKey hold one of volume_up, volume_down, screenshot or shortcut_app with the component alongside in VolumeUpAppShortcut / VolumeDownAppShortcut. Also switches to AGP 8.13.2 / Kotlin 2.2.21 on a pinned Gradle 8.14.3 wrapper, raises minSdk to 31 for QuickAccessWalletService, and adds release signing read from an uncommitted keystore.properties.
.gitignore | 19 +- README.md | 126 +++++++++++ app/build.gradle.kts | 48 +++- app/src/main/AndroidManifest.xml | 132 ++++++++++- .../dev/equwal/assistkey/channel/AssistActivity.kt | 19 ++ .../equwal/assistkey/channel/CameraShimActivity.kt | 18 ++ .../java/dev/equwal/assistkey/channel/Channel.kt | 86 +++++++ .../dev/equwal/assistkey/channel/ChannelEntry.kt | 61 +++++ .../java/dev/equwal/assistkey/channel/Channels.kt | 169 ++++++++++++++ .../dev/equwal/assistkey/channel/WalletActivity.kt | 16 ++ .../dev/equwal/assistkey/channel/WalletService.kt | 26 +++ .../dev/equwal/assistkey/engine/GestureEngine.kt | 2 +- .../equwal/assistkey/engine/KeyFilterService.kt | 138 ++++++++++++ .../dev/equwal/assistkey/native/PowerNative.kt | 108 +++++++++ .../dev/equwal/assistkey/native/ViwoodsBridge.kt | 128 +++++++++++ .../equwal/assistkey/ui/ActionPickerActivity.kt | 183 +++++++++++++++ .../java/dev/equwal/assistkey/ui/ChordActivity.kt | 82 +++++++ .../java/dev/equwal/assistkey/ui/MainActivity.kt | 161 +++++++++++++ .../java/dev/equwal/assistkey/ui/PowerActivity.kt | 222 ++++++++++++++++++ .../java/dev/equwal/assistkey/ui/TimingActivity.kt | 90 ++++++++ .../dev/equwal/assistkey/ui/TriggerListActivity.kt | 87 ++++++++ app/src/main/java/dev/equwal/assistkey/ui/Ui.kt | 212 ++++++++++++++++++ .../dev/equwal/assistkey/ui/ViwoodsActivity.kt | 127 +++++++++++ .../main/res/drawable/ic_launcher_foreground.xml | 20 ++ app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 29 +-- app/src/main/res/values/styles.xml | 10 +- .../main/res/xml/accessibility_service_config.xml | 16 +- app/src/main/res/xml/quick_access_wallet.xml | 11 + build.gradle.kts | 6 +- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 +++++++++++++++++++++ gradlew.bat | 82 +++++++ 36 files changed, 2643 insertions(+), 64 deletions(-)
diff --git a/.gitignore b/.gitignore index 5586b55..844ebd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,24 @@ # Build output build/ -app/build/ .gradle/ *.apk *.aab *.ap_ *.dex -# Local toolchain paths - machine specific +# Local machine configuration local.properties +build.ps1 -# IDE -.idea/ -*.iml -.vscode/ - -# Signing material must never be committed +# Signing material - the keystore and its passwords never go in the repo. +# Losing assistkey-release.jks means shipped installs can never be upgraded, +# so keep a backup somewhere outside this folder. *.jks *.keystore keystore.properties -# OS -Thumbs.db +# Editors +.idea/ +*.iml +.vscode/ .DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..77f02f0 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# AssistKey + +Remaps the four hardware keys on a **Viwoods AiPaper Reader** — the AI key, both +volume keys, and the power button — to arbitrary actions, including multi-tap, +press-and-hold and key combinations. + +Built against firmware 1.5.6 (Android 16, SDK 36). No dependencies beyond the +Android framework; the APK is about 2 MB and everything in it is in this repo. + +## Why it is shaped like this + +Android does not offer one way to intercept hardware keys. It offers several +partial ones, each with different reach, and the power button has none at all. +So the app is organised around **capture channels**: independent routes by which +a key press can be made to arrive at this app. You tick the ones you want. + +| Channel | Captures | How | Needs | +|---|---|---|---| +| **Accessibility key filter** | AI key, Volume up, Volume down | `AccessibilityService.onKeyEvent` with `flagRequestFilterKeyEvents` | Service enabled in Settings | +| **Digital assistant** | Power — press and hold | Holds `ROLE_ASSISTANT`; the firmware fires `ACTION_ASSIST` at the role holder | Role granted; firmware long-press set to Assistant | +| **Camera app** | Power — double press | Becomes the default camera, so the double-press camera gesture lands here | Set as default camera app | +| **Wallet app** | Power — double press, wallet tile, lock-screen wallet button | Holds `ROLE_WALLET` and serves a (empty) `QuickAccessWalletService` | Role granted | +| **Viwoods native hooks** | AI key, Volume up, Volume down | Rewrites the firmware's own `Settings.System` key bindings | `WRITE_SETTINGS` (grantable in-app) | + +### The power button is not like the others + +`PhoneWindowManager.interceptKeyBeforeQueueing()` consumes `KEYCODE_POWER` +before the input dispatcher runs, so **no** accessibility service, on any +Android version, can see it. That leaves exactly three reachable slots: + +- **Short press** — firmware only. The app rewrites + `Settings.Global.power_button_short_press`, so it can become Home, or nothing, + instead of sleep. No app code runs. +- **Double press** — arrives as a camera or wallet launch, depending on what the + firmware's double-press target is set to. Both channels are offered. +- **Press and hold** — arrives as an assistant request. + +No multi-tap beyond two, and Power can never be half of a combination. +`Power + Volume up` is reserved by the firmware for the power menu, which the +app deliberately leaves alone as an escape hatch. + +### Everything else + +The AI key and both volume keys go through the accessibility filter, which gives +the full vocabulary: 1–5 taps, press-and-hold, and any combination of those +three keys, each bindable separately. + +The gesture engine (`engine/GestureEngine.kt`) is deliberately free of Android +types so the state machine is unit-testable on the JVM. Its one rule that +matters for feel: *a key with no bindings is never consumed, and a key whose +highest bound tap count is 1 fires on key-up without waiting out the multi-tap +window.* You only pay multi-tap latency on keys where you actually asked for a +double tap. + +## Actions + +Parity with the stock Viwoods key screen, plus everything it does not offer: + +- **Viwoods AI** — AI crop, AI lookup, quick prompt, full assistant, history, + repository, edit screenshot, Viwoods home +- **Navigation** — Back, Home, Recents, notification shade, quick settings, + power menu, lock screen, screenshot, D-pad, play/pause +- **Page turning** — synthetic swipes and scrolls, for readers that only take + touch +- **Sound and media** — volume up/down/mute/panel, play-pause, next, previous +- **Anything else** — open an app, open a specific `package/class` (reaches + activities with no launcher icon), send an intent action, send a broadcast +- **Do nothing** — swallows the key, which is how a button gets disabled + +## Installing + +Grab `app-release.apk` from the release build and sideload it. Then, optionally: + +```bash +adb shell pm grant dev.equwal.assistkey android.permission.WRITE_SECURE_SETTINGS +``` + +Without that grant everything still works except the firmware-level power +switches (short press, hold duration, the double-press gesture toggles). The app +detects the refusal and prints the exact `adb` command instead of failing +silently. + +## Building + +```bash +./gradlew assembleRelease +``` + +Release signing is read from `keystore.properties` at the repo root, which is +not committed: + +```properties +storeFile=assistkey-release.jks +storePassword=… +keyAlias=assistkey +keyPassword=… +``` + +Without it the release build still runs and produces an unsigned APK, so a fresh +clone is never broken — it just cannot ship. **Back up the keystore**: losing it +means shipped installs can never be upgraded. + +### Windows note + +If Gradle dies with `Unable to establish loopback connection`, `java.io.tmpdir` +is resolving through an 8.3 short path (`C:\Users\ADMINI~1\…`), which breaks the +JDK's AF_UNIX-backed `Selector.open()`. Point `TMP` and `TEMP` at a short path +such as `C:\tmp` and it goes away. + +## Uninstalling cleanly + +Uninstalling restores every key to firmware default **except** the ones written +through the Viwoods channel — those live in system settings and outlive the app. +Reset them from *Firmware key hooks → Restore all three keys to factory* first. + +## Layout + +``` +model/ keys, triggers, action specs, the recovered Viwoods component list +engine/ the gesture state machine and the accessibility service +channel/ the five capture channels and their entry points +native/ firmware settings: power gestures and the Viwoods key hooks +route/ turning an action spec into behaviour +store/ persistence, with the key-event hot path precomputed +ui/ the configuration screens, built in code +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc05836..093d352 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,26 +1,56 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") } +/** + * Release signing comes from keystore.properties, which is deliberately not in + * the repository. Without it the release build still runs and produces an + * unsigned APK, so a fresh clone is never broken - it just cannot ship. + */ +val keystoreProps = Properties().apply { + val f = rootProject.file("keystore.properties") + if (f.exists()) f.inputStream().use { load(it) } +} +val hasSigning = keystoreProps.getProperty("storeFile") != null + android { namespace = "dev.equwal.assistkey" compileSdk = 36 defaultConfig { applicationId = "dev.equwal.assistkey" - // 30 is the floor for GLOBAL_ACTION_TAKE_SCREENSHOT. - minSdk = 30 + // QuickAccessWalletService, which the wallet channel needs, is API 31. + minSdk = 31 targetSdk = 36 versionCode = 1 versionName = "1.0" } + signingConfigs { + if (hasSigning) { + create("release") { + storeFile = rootProject.file(keystoreProps.getProperty("storeFile")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") + } + } + } + buildTypes { release { - // No shrinking: the app is tiny and R8 would only complicate - // keeping the accessibility service and assist activity reachable. + // No shrinking: the app is tiny, and R8 would only complicate + // keeping the accessibility service and the three impersonation + // entry points reachable. isMinifyEnabled = false + if (hasSigning) signingConfig = signingConfigs.getByName("release") + } + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" } } @@ -32,8 +62,14 @@ android { kotlinOptions { jvmTarget = "17" } + + lint { + // The whole app is built out of things a normal app has no business + // doing; the interesting warnings are drowned out by the expected ones. + abortOnError = false + } } -// Deliberately no dependencies - framework APIs only, so the APK stays a few KB -// and there is nothing to audit but our own code. +// Framework APIs only, so the APK stays a couple of hundred KB and there is +// nothing to audit but our own code. dependencies { } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ededa44..ac1de4d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,16 +1,50 @@ <?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <!-- User-grantable from a settings screen; drives the Viwoods channel. --> + <uses-permission android:name="android.permission.WRITE_SETTINGS" + tools:ignore="ProtectedPermissions" /> + + <!-- + Not grantable at install. Declared so that `adb shell pm grant` can turn + it on for the power-gesture switches; without it those rows report the + refusal and print the command instead. + --> + <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" + tools:ignore="ProtectedPermissions" /> + + <!-- Required of any app that wants to be offered the wallet role. --> + <uses-permission android:name="android.permission.QUICK_ACCESS_WALLET" /> + + <!-- + Package visibility. We need to see launchable apps to offer them as + targets, resolve the camera intent to tell whether we are the default, + and reach the Viwoods AI activities by explicit component. + --> + <queries> + <intent> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent> + <intent> + <action android:name="android.media.action.STILL_IMAGE_CAMERA" /> + </intent> + <package android:name="com.viwoods.launcher" /> + <package android:name="com.viwoods.viwoodsai" /> + <package android:name="com.viwoods.settings" /> + </queries> <application android:allowBackup="true" - android:icon="@android:drawable/ic_menu_preferences" + android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@android:style/Theme.DeviceDefault.Light"> + android:theme="@style/Theme.App"> - <!-- Config UI --> + <!-- Configuration UI --> <activity - android:name=".MainActivity" + android:name=".ui.MainActivity" android:exported="true" android:label="@string/app_name"> <intent-filter> @@ -19,14 +53,27 @@ </intent-filter> </activity> + <activity android:name=".ui.TriggerListActivity" android:exported="false" /> + <activity android:name=".ui.ActionPickerActivity" android:exported="false" /> + <activity android:name=".ui.PowerActivity" android:exported="false" /> + <activity android:name=".ui.ChordActivity" android:exported="false" /> + <activity android:name=".ui.ViwoodsActivity" android:exported="false" /> + <activity android:name=".ui.TimingActivity" android:exported="false" /> + <!-- - This is the whole trick. Long-pressing power fires ACTION_ASSIST at - whichever app holds the assistant role. We claim it with an invisible, - no-history activity that fires the action and immediately finishes, - so nothing is ever drawn on screen. + The three impersonation entry points. Each starts disabled: the app + must not show up as an assistant, camera or wallet candidate until + the user ticks that channel. Channels.syncComponents flips them. + --> + + <!-- + Long-pressing Power fires ACTION_ASSIST at whichever app holds the + assistant role. Invisible, no-history, no task affinity, so nothing + is drawn and nothing lands in Recents. --> <activity - android:name=".AssistActivity" + android:name=".channel.AssistActivity" + android:enabled="false" android:exported="true" android:excludeFromRecents="true" android:noHistory="true" @@ -43,9 +90,70 @@ </intent-filter> </activity> - <!-- Back and Recents are only reachable through global accessibility actions. --> + <!-- Double press Power, when the firmware target is the camera. --> + <activity + android:name=".channel.CameraShimActivity" + android:enabled="false" + android:exported="true" + android:excludeFromRecents="true" + android:noHistory="true" + android:launchMode="singleInstance" + android:taskAffinity="" + android:theme="@style/Theme.Invisible"> + <intent-filter> + <action android:name="android.media.action.STILL_IMAGE_CAMERA" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + <intent-filter> + <action android:name="android.media.action.STILL_IMAGE_CAMERA_SECURE" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + <intent-filter> + <action android:name="android.media.action.IMAGE_CAPTURE" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + + <!-- Double press Power when the target is the wallet, plus the QS tile. --> + <activity + android:name=".channel.WalletActivity" + android:enabled="false" + android:exported="true" + android:excludeFromRecents="true" + android:noHistory="true" + android:launchMode="singleInstance" + android:taskAffinity="" + android:theme="@style/Theme.Invisible"> + <intent-filter> + <action android:name="android.service.quickaccesswallet.action.VIEW_WALLET" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + + <!-- + The wallet role is not offered to an app that cannot serve cards, so + this exists to satisfy the requirement. It serves none. + --> + <service + android:name=".channel.WalletService" + android:enabled="false" + android:exported="true" + android:label="@string/wallet_label" + android:permission="android.permission.BIND_QUICK_ACCESS_WALLET_SERVICE"> + <intent-filter> + <action android:name="android.service.quickaccesswallet.QuickAccessWalletService" /> + </intent-filter> + <meta-data + android:name="android.quickaccesswallet" + android:resource="@xml/quick_access_wallet" /> + </service> + + <!-- + The accessibility channel. This is the only component that sees the + AI key and the volume keys; Power never reaches it. + --> <service - android:name=".KeyAccessibilityService" + android:name=".engine.KeyFilterService" android:exported="false" android:label="@string/service_label" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> diff --git a/app/src/main/java/dev/equwal/assistkey/channel/AssistActivity.kt b/app/src/main/java/dev/equwal/assistkey/channel/AssistActivity.kt new file mode 100644 index 0000000..d17f4bc --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/AssistActivity.kt @@ -0,0 +1,19 @@ +package dev.equwal.assistkey.channel + +import android.app.Activity +import android.os.Bundle + +/** + * The assistant role holder. Long-pressing Power fires ACTION_ASSIST at + * whichever app holds the role, which is how a gesture the input dispatcher + * never sees becomes an ordinary activity launch we can act on. + * + * Invisible, no-history, no task affinity: nothing is ever drawn and nothing + * is left in Recents. + */ +class AssistActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ChannelEntry.handle(this, Channel.ASSISTANT) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/CameraShimActivity.kt b/app/src/main/java/dev/equwal/assistkey/channel/CameraShimActivity.kt new file mode 100644 index 0000000..77373e2 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/CameraShimActivity.kt @@ -0,0 +1,18 @@ +package dev.equwal.assistkey.channel + +import android.app.Activity +import android.os.Bundle + +/** + * Stands in for the camera app so that a double press of Power lands here. + * + * Only useful when the firmware double-press target is Camera and this app is + * the default camera app - which also means no real camera is reachable that + * way while the channel is on. That is the trade the checkbox represents. + */ +class CameraShimActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ChannelEntry.handle(this, Channel.CAMERA) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt new file mode 100644 index 0000000..63ff709 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt @@ -0,0 +1,86 @@ +package dev.equwal.assistkey.channel + +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger + +/** + * A capture channel: one way the system can be persuaded to hand us control. + * + * Each is independently switchable because they compete for different system + * slots and their availability varies by firmware. Turning one on enables its + * manifest components; turning it off disables them, so the app stops showing + * up as a candidate anywhere in Settings. + * + * Power never reaches an input filter - PhoneWindowManager eats it before the + * dispatcher - so the three impersonation channels exist purely to get the + * power gestures routed back to us as ordinary app launches. + */ +enum class Channel( + val key: String, + val title: String, + val summary: String, + /** Manifest components enabled/disabled along with the channel. */ + val components: List<String> +) { + ACCESSIBILITY( + "accessibility", + "Accessibility key filter", + "Captures the AI key and both volume keys. Required for multi-tap, " + + "holds, chords, and for Back / Recents / swipe actions.", + listOf() + ), + + ASSISTANT( + "assistant", + "Digital assistant", + "Captures long-press Power by holding the assistant role.", + listOf("dev.equwal.assistkey.channel.AssistActivity") + ), + + CAMERA( + "camera", + "Camera app", + "Captures double-press Power when the gesture target is Camera. " + + "Needs this app set as the default camera app.", + listOf("dev.equwal.assistkey.channel.CameraShimActivity") + ), + + WALLET( + "wallet", + "Wallet app", + "Captures double-press Power when the gesture target is Wallet, plus " + + "the lock-screen wallet button and the quick-settings wallet tile.", + listOf( + "dev.equwal.assistkey.channel.WalletActivity", + "dev.equwal.assistkey.channel.WalletService" + ) + ), + + VIWOODS( + "viwoods", + "Viwoods native key hooks", + "Rebinds the firmware key settings directly. Works without the " + + "accessibility service, but single-action only - no multi-tap.", + listOf() + ); + + /** + * The gesture this channel stands in for, so an impersonation entry point + * resolves to the same binding the user edited on the Power screen. Null + * for channels that are not a single fixed gesture. + */ + val trigger: Trigger? + get() = when (this) { + ASSISTANT -> POWER_HOLD + CAMERA, WALLET -> POWER_DOUBLE + ACCESSIBILITY, VIWOODS -> null + } + + companion object { + val POWER_HOLD = Trigger(setOf(HwKey.POWER), GestureType.HOLD) + val POWER_DOUBLE = Trigger(setOf(HwKey.POWER), GestureType.TAP, 2) + + fun fromKey(k: String): Channel? = entries.firstOrNull { it.key == k } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt new file mode 100644 index 0000000..37e9101 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt @@ -0,0 +1,61 @@ +package dev.equwal.assistkey.channel + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.Toast +import dev.equwal.assistkey.route.ActionRouter +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.store.Store + +/** + * Shared body of the three impersonation entry points. They differ only in + * which intent filters bring them to life. + */ +object ChannelEntry { + + private const val TAG = "AssistKey" + + /** + * Ordering matters. A global accessibility action such as Back or Recents + * applies to whatever window is on top, so we have to be gone first; + * anything else we run while still foreground, otherwise the background + * activity launch restrictions would drop it. + */ + fun handle(activity: Activity, channel: Channel) { + val trigger = channel.trigger + val spec = trigger?.let { Store.bindings(activity)[it] } + + if (spec == null) { + Log.i(TAG, "channel " + channel.key + " fired with nothing bound") + activity.finishAndVanish() + return + } + + if (ActionRouter.requiresAccessibility(spec)) { + if (!ServiceHolder.isRunning) { + Toast.makeText( + activity, + "Enable the AssistKey accessibility service to use this action", + Toast.LENGTH_SHORT + ).show() + activity.finishAndVanish() + return + } + val app = activity.applicationContext + activity.finishAndVanish() + Handler(Looper.getMainLooper()).postDelayed({ ActionRouter.run(app, spec) }, 150L) + } else { + ActionRouter.run(activity, spec) + activity.finishAndVanish() + } + } + + /** No window was ever drawn; make sure no transition animates one in. */ + private fun Activity.finishAndVanish() { + finish() + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt new file mode 100644 index 0000000..c719ebe --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt @@ -0,0 +1,169 @@ +package dev.equwal.assistkey.channel + +import android.app.role.RoleManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import dev.equwal.assistkey.engine.KeyFilterService + +/** + * Turning channels on and off, and telling the truth about whether the system + * is actually routing anything to them. + * + * "Enabled" is our own switch. "Satisfied" is whether the system agrees - the + * role is held, the service is running, the permission is granted. The UI + * shows both because an enabled-but-unsatisfied channel is the normal state + * right after the user ticks the box, and it needs a visible next step. + */ +object Channels { + + const val ROLE_WALLET = "android.app.role.WALLET" + const val ACTION_VIEW_WALLET = "android.service.quickaccesswallet.action.VIEW_WALLET" + const val ACTION_STILL_IMAGE_CAMERA = "android.media.action.STILL_IMAGE_CAMERA" + + private const val PREFS = "assistkey_channels" + + private fun prefs(c: Context) = + c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun isEnabled(c: Context, ch: Channel): Boolean = + prefs(c).getBoolean(ch.key, false) + + fun setEnabled(c: Context, ch: Channel, on: Boolean) { + prefs(c).edit().putBoolean(ch.key, on).apply() + applyComponents(c, ch, on) + } + + fun enabled(c: Context): List<Channel> = Channel.entries.filter { isEnabled(c, it) } + + /** + * A disabled channel has its components disabled too, so the app stops + * being offered as an assistant / camera / wallet candidate at all. Called + * on every launch as well, in case a reinstall reset the component states. + */ + fun syncComponents(c: Context) { + Channel.entries.forEach { applyComponents(c, it, isEnabled(c, it)) } + } + + private fun applyComponents(c: Context, ch: Channel, on: Boolean) { + val pm = c.packageManager + val want = if (on) { + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + ch.components.forEach { cls -> + runCatching { + val cn = ComponentName(c.packageName, cls) + if (pm.getComponentEnabledSetting(cn) != want) { + pm.setComponentEnabledSetting( + cn, want, android.content.pm.PackageManager.DONT_KILL_APP + ) + } + } + } + } + + // ---- what the system thinks ------------------------------------------- + + fun isSatisfied(c: Context, ch: Channel): Boolean = when (ch) { + Channel.ACCESSIBILITY -> isAccessibilityOn(c) + Channel.ASSISTANT -> isRoleHeld(c, RoleManager.ROLE_ASSISTANT) + Channel.WALLET -> isRoleHeld(c, ROLE_WALLET) + Channel.CAMERA -> isDefaultCamera(c) + Channel.VIWOODS -> Settings.System.canWrite(c) + } + + /** One line for the UI, describing exactly what is missing. */ + fun status(c: Context, ch: Channel): String { + if (!isEnabled(c, ch)) return "Off" + if (isSatisfied(c, ch)) return "Active" + return when (ch) { + Channel.ACCESSIBILITY -> "Service not enabled in Settings" + Channel.ASSISTANT -> "Not the default digital assistant" + Channel.WALLET -> + if (isRoleAvailable(c, ROLE_WALLET)) "Not the default wallet app" + else "No wallet role on this firmware" + Channel.CAMERA -> "Not the default camera app" + Channel.VIWOODS -> "Needs permission to modify system settings" + } + } + + fun isAccessibilityOn(c: Context): Boolean { + val want = ComponentName(c.packageName, KeyFilterService::class.java.name) + val list = Settings.Secure.getString( + c.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + return list.split(":").any { + ComponentName.unflattenFromString(it.trim()) == want + } + } + + private fun roleManager(c: Context): RoleManager? = + c.getSystemService(RoleManager::class.java) + + fun isRoleAvailable(c: Context, role: String): Boolean = + runCatching { roleManager(c)?.isRoleAvailable(role) == true }.getOrDefault(false) + + fun isRoleHeld(c: Context, role: String): Boolean = + runCatching { roleManager(c)?.isRoleHeld(role) == true }.getOrDefault(false) + + private fun isDefaultCamera(c: Context): Boolean { + val i = Intent(ACTION_STILL_IMAGE_CAMERA) + val r = c.packageManager.resolveActivity(i, 0) ?: return false + return r.activityInfo?.packageName == c.packageName + } + + // ---- how the user grants it ------------------------------------------- + + /** + * Where to send the user to satisfy a channel. Role requests get a real + * system dialog; everything else falls back to the closest Settings screen, + * because no firmware exposes a direct intent for default camera. + */ + fun claimIntent(c: Context, ch: Channel): Intent = when (ch) { + Channel.ACCESSIBILITY -> Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + + Channel.ASSISTANT -> roleRequest(c, RoleManager.ROLE_ASSISTANT) + ?: Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) + + Channel.WALLET -> roleRequest(c, ROLE_WALLET) + ?: defaultApps() + + Channel.CAMERA -> defaultApps() + + Channel.VIWOODS -> Intent( + Settings.ACTION_MANAGE_WRITE_SETTINGS, + Uri.parse("package:" + c.packageName) + ) + }.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + private fun roleRequest(c: Context, role: String): Intent? { + if (!isRoleAvailable(c, role)) return null + return runCatching { roleManager(c)?.createRequestRoleIntent(role) }.getOrNull() + } + + private fun defaultApps(): Intent = + Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) + + /** + * Not every firmware has the default-apps screen wired up; fall back to the + * top-level Settings rather than crashing on an unresolvable intent. + */ + fun safeStart(c: Context, intent: Intent): Boolean { + if (intent.resolveActivity(c.packageManager) != null) { + c.startActivity(intent); return true + } + val fallback = Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (fallback.resolveActivity(c.packageManager) != null) { + c.startActivity(fallback); return true + } + return false + } + + /** SDK_INT is read in a couple of UI spots; keep the lookup in one place. */ + val sdk: Int get() = Build.VERSION.SDK_INT +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/WalletActivity.kt b/app/src/main/java/dev/equwal/assistkey/channel/WalletActivity.kt new file mode 100644 index 0000000..b1a0280 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/WalletActivity.kt @@ -0,0 +1,16 @@ +package dev.equwal.assistkey.channel + +import android.app.Activity +import android.os.Bundle + +/** + * The wallet role holder. Reached by a double press of Power when the firmware + * target is Wallet, and also by the lock-screen wallet button and the + * quick-settings tile - three routes into the same binding. + */ +class WalletActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ChannelEntry.handle(this, Channel.WALLET) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/channel/WalletService.kt b/app/src/main/java/dev/equwal/assistkey/channel/WalletService.kt new file mode 100644 index 0000000..82682e8 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/channel/WalletService.kt @@ -0,0 +1,26 @@ +package dev.equwal.assistkey.channel + +import android.service.quickaccesswallet.GetWalletCardsCallback +import android.service.quickaccesswallet.GetWalletCardsRequest +import android.service.quickaccesswallet.GetWalletCardsResponse +import android.service.quickaccesswallet.QuickAccessWalletService +import android.service.quickaccesswallet.SelectWalletCardRequest + +/** + * Present only because the wallet role will not be offered to an app that + * cannot serve cards. It serves none - the useful half of the channel is + * [WalletActivity], which is where the role holder actually gets launched. + */ +class WalletService : QuickAccessWalletService() { + + override fun onWalletCardsRequested( + request: GetWalletCardsRequest, + callback: GetWalletCardsCallback + ) { + callback.onSuccess(GetWalletCardsResponse(emptyList(), 0)) + } + + override fun onWalletCardSelected(request: SelectWalletCardRequest) { /* no cards */ } + + override fun onWalletDismissed() { /* nothing to tear down */ } +} diff --git a/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt index 20da1d7..25e7afb 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt @@ -18,7 +18,7 @@ import dev.equwal.assistkey.model.Trigger */ class GestureEngine( private val host: Host, - private val cfg: Config = Config() + var cfg: Config = Config() ) { data class Config( diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt new file mode 100644 index 0000000..277b123 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -0,0 +1,138 @@ +package dev.equwal.assistkey.engine + +import android.accessibilityservice.AccessibilityService +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.accessibility.AccessibilityEvent +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Presets +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.route.ActionRouter +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.store.Store + +/** + * The accessibility channel: filters hardware key events and feeds them to + * [GestureEngine]. + * + * Power never arrives here - the framework consumes it upstream - so this + * service only ever sees the AI key and the volume keys. + */ +class KeyFilterService : AccessibilityService(), GestureEngine.Host { + + private val handler = Handler(Looper.getMainLooper()) + private val pending = HashMap<String, Runnable>() + private lateinit var engine: GestureEngine + + override fun onServiceConnected() { + super.onServiceConnected() + engine = GestureEngine(this, Store.timing(this)) + ServiceHolder.service = this + } + + override fun onUnbind(intent: android.content.Intent?): Boolean { + cancelAll() + ServiceHolder.service = null + return super.onUnbind(intent) + } + + override fun onDestroy() { + cancelAll() + ServiceHolder.service = null + super.onDestroy() + } + + override fun onKeyEvent(event: KeyEvent): Boolean { + if (!Channels.isEnabled(this, Channel.ACCESSIBILITY)) return false + val key = HwKey.fromCode(event.keyCode) ?: return false + if (!key.interceptable) return false + + return when (event.action) { + KeyEvent.ACTION_DOWN -> engine.onDown(key, event.eventTime, event.repeatCount) + KeyEvent.ACTION_UP -> engine.onUp(key, event.eventTime) + else -> false + } + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* not used */ } + + /** + * The settings screen lives in this same process, so new timing values can + * be handed straight to the running engine - no service restart, and no + * gesture lost mid-press. + */ + fun reloadTiming() { + if (::engine.isInitialized) engine.cfg = Store.timing(this) + } + + override fun onInterrupt() { + engine.onCancel() + } + + // ---- GestureEngine.Host ------------------------------------------------- + + private fun bindings() = Store.bindings(this) + + override fun maxTaps(keys: Set<HwKey>): Int = bindings().maxTaps(keys) + + override fun hasHold(keys: Set<HwKey>): Boolean = bindings().hasHold(keys) + + override fun isBound(trigger: Trigger): Boolean = bindings().isBound(trigger) + + override fun chordPartners(key: HwKey): Set<HwKey> = bindings().chordPartners(key) + + override fun fire(trigger: Trigger) { + val spec = bindings()[trigger] ?: return + ActionRouter.run(this, spec) + } + + /** + * We swallowed the key-down, so anything the system would normally have + * done has to be reproduced by hand. + */ + override fun passThrough(keys: Set<HwKey>, longPress: Boolean) { + if (keys.size != 1) return + when (keys.first()) { + HwKey.VOL_UP -> adjust(AudioManager.ADJUST_RAISE) + HwKey.VOL_DOWN -> adjust(AudioManager.ADJUST_LOWER) + HwKey.AI -> ActionRouter.run( + this, + ActionSpec(ActionKind.LAUNCH_COMPONENT, Presets.STOCK_AI_KEY) + ) + HwKey.POWER -> Unit + } + } + + private fun adjust(direction: Int) { + getSystemService(AudioManager::class.java)?.adjustSuggestedStreamVolume( + direction, + AudioManager.USE_DEFAULT_STREAM_TYPE, + AudioManager.FLAG_SHOW_UI + ) + } + + override fun schedule(token: String, delayMs: Long, action: () -> Unit) { + cancel(token) + val r = Runnable { + pending.remove(token) + action() + } + pending[token] = r + handler.postDelayed(r, delayMs) + } + + override fun cancel(token: String) { + pending.remove(token)?.let { handler.removeCallbacks(it) } + } + + private fun cancelAll() { + pending.values.forEach { handler.removeCallbacks(it) } + pending.clear() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt b/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt new file mode 100644 index 0000000..f227fe3 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt @@ -0,0 +1,108 @@ +package dev.equwal.assistkey.native + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.provider.Settings + +/** + * The firmware's own power-button behaviour, which lives in Settings.Global / + * Settings.Secure rather than in anything an app can intercept. + * + * This is the only way to touch short-press Power at all: no app ever sees that + * event. Writing here needs WRITE_SECURE_SETTINGS, which is not grantable from + * a settings screen - it has to come over adb - so every write is attempted and + * reported rather than assumed. + */ +object PowerNative { + + const val GRANT_COMMAND = + "adb shell pm grant dev.equwal.assistkey android.permission.WRITE_SECURE_SETTINGS" + + private const val SHORT_PRESS = "power_button_short_press" + private const val LONG_PRESS = "power_button_long_press" + private const val LONG_PRESS_MS = "power_button_long_press_duration_ms" + private const val CHORD_VOL_UP = "key_chord_power_volume_up" + private const val CAMERA_DOUBLE_TAP = "camera_double_tap_power_gesture_disabled" + private const val DOUBLE_TAP = "double_tap_power_button_gesture_enabled" + + /** A firmware behaviour with its raw value, for a plain radio list. */ + data class Option(val value: Int, val label: String, val note: String = "") + + /** + * SHORT_PRESS_POWER_* in PhoneWindowManager. 6 and above only exist on + * newer builds; an unsupported value is simply ignored by the framework, + * so they are offered with a warning rather than hidden. + */ + val shortPress = listOf( + Option(1, "Sleep", "Stock behaviour"), + Option(0, "Nothing", "Power becomes a free button - use with a long-press binding"), + Option(4, "Home"), + Option(5, "Close keyboard, else Home"), + Option(2, "Sleep immediately"), + Option(3, "Sleep immediately and go Home"), + Option(6, "Lock, else sleep", "Newer builds only"), + Option(7, "Screensaver, else sleep", "Newer builds only") + ) + + /** LONG_PRESS_POWER_* in PhoneWindowManager. */ + val longPress = listOf( + Option(5, "Digital assistant", "Required for the Assistant channel"), + Option(1, "Power menu", "Stock behaviour on most devices"), + Option(0, "Nothing"), + Option(4, "Voice assist"), + Option(2, "Power off (with confirmation)"), + Option(3, "Power off immediately", "No confirmation - be careful") + ) + + /** KEY_CHORD_POWER_VOLUME_UP. Keep an escape hatch to the power menu. */ + val chordVolumeUp = listOf( + Option(2, "Power menu", "Recommended escape hatch"), + Option(1, "Mute"), + Option(0, "Nothing") + ) + + fun canWriteSecure(c: Context): Boolean = + c.checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) == + PackageManager.PERMISSION_GRANTED + + // ---- reads (always allowed) ------------------------------------------- + + private fun cr(c: Context): ContentResolver = c.contentResolver + + fun shortPressValue(c: Context): Int = Settings.Global.getInt(cr(c), SHORT_PRESS, 1) + fun longPressValue(c: Context): Int = Settings.Global.getInt(cr(c), LONG_PRESS, 1) + fun longPressMs(c: Context): Int = Settings.Global.getInt(cr(c), LONG_PRESS_MS, 500) + fun chordVolumeUpValue(c: Context): Int = Settings.Global.getInt(cr(c), CHORD_VOL_UP, 2) + + /** Inverted in the framework: the setting records "disabled". */ + fun cameraDoubleTapEnabled(c: Context): Boolean = + Settings.Secure.getInt(cr(c), CAMERA_DOUBLE_TAP, 0) == 0 + + fun doubleTapGestureEnabled(c: Context): Boolean = + Settings.Secure.getInt(cr(c), DOUBLE_TAP, 0) == 1 + + // ---- writes (need the adb grant) -------------------------------------- + + fun setShortPress(c: Context, v: Int) = putGlobal(c, SHORT_PRESS, v) + fun setLongPress(c: Context, v: Int) = putGlobal(c, LONG_PRESS, v) + fun setLongPressMs(c: Context, v: Int) = putGlobal(c, LONG_PRESS_MS, v) + fun setChordVolumeUp(c: Context, v: Int) = putGlobal(c, CHORD_VOL_UP, v) + fun setCameraDoubleTapEnabled(c: Context, on: Boolean) = + putSecure(c, CAMERA_DOUBLE_TAP, if (on) 0 else 1) + fun setDoubleTapGestureEnabled(c: Context, on: Boolean) = + putSecure(c, DOUBLE_TAP, if (on) 1 else 0) + + private fun putGlobal(c: Context, k: String, v: Int): Boolean = + runCatching { Settings.Global.putInt(cr(c), k, v) }.getOrDefault(false) + + private fun putSecure(c: Context, k: String, v: Int): Boolean = + runCatching { Settings.Secure.putInt(cr(c), k, v) }.getOrDefault(false) + + /** So the user can paste the exact command when a write is refused. */ + fun adbFallback(scope: String, key: String, value: Int): String = + "adb shell settings put " + scope + " " + key + " " + value + + fun shortPressAdb(v: Int) = adbFallback("global", SHORT_PRESS, v) + fun longPressAdb(v: Int) = adbFallback("global", LONG_PRESS, v) +} diff --git a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt new file mode 100644 index 0000000..40dae98 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt @@ -0,0 +1,128 @@ +package dev.equwal.assistkey.native + +import android.content.ComponentName +import android.content.Context +import android.provider.Settings +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Presets + +/** + * The firmware's own key remapper, which turns out to be nothing more than a + * handful of Settings.System strings that the Viwoods launcher reads. The names + * and the token vocabulary below were read out of setting_se08 1.3.7 on + * firmware 1.5.6. + * + * Writing them needs WRITE_SETTINGS, which the user can grant from an ordinary + * settings screen - no adb - so this is the one channel that works on a + * completely untouched device. The cost is one action per key: no multi-tap, + * no holds, no chords. + * + * The two key families are not symmetric. The volume keys hold a token, with + * the component kept alongside in *AppShortcut; the AI key holds the flat + * component itself and has no shortcut slot. + */ +object ViwoodsBridge { + + private const val AI_KEY = "CustomAiKey" + + private val VOLUME_SLOT = mapOf( + HwKey.VOL_UP to Slot("CustomVolumeUpKey", "VolumeUpAppShortcut"), + HwKey.VOL_DOWN to Slot("CustomVolumeDownKey", "VolumeDownAppShortcut") + ) + + data class Slot(val key: String, val shortcut: String) + + const val TOKEN_APP = "shortcut_app" + + /** Every value the stock launcher understands for a volume key. */ + val volumeTokens = listOf( + "volume_up" to "Volume up", + "volume_down" to "Volume down", + "screenshot" to "Screenshot", + TOKEN_APP to "Open an app" + ) + + fun keys(): List<HwKey> = listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN) + + fun canWrite(c: Context): Boolean = Settings.System.canWrite(c) + + // ---- reads ------------------------------------------------------------- + + fun read(c: Context, key: HwKey): String? = when (key) { + HwKey.AI -> Settings.System.getString(c.contentResolver, AI_KEY) + else -> VOLUME_SLOT[key]?.let { + Settings.System.getString(c.contentResolver, it.key) + } + } + + fun readShortcut(c: Context, key: HwKey): String? = + VOLUME_SLOT[key]?.let { Settings.System.getString(c.contentResolver, it.shortcut) } + + /** Human description of whatever the firmware currently has bound. */ + fun describe(c: Context, key: HwKey): String { + val v = read(c, key) + if (v.isNullOrBlank()) return "Not set (firmware default)" + if (key == HwKey.AI) return shortName(c, v) + + val named = volumeTokens.firstOrNull { it.first == v } ?: return shortName(c, v) + if (v != TOKEN_APP) return named.second + val target = readShortcut(c, key) + return if (target.isNullOrBlank()) "Open an app (none chosen)" + else "Open " + shortName(c, target) + } + + // ---- writes ------------------------------------------------------------ + + /** + * Returns false when WRITE_SETTINGS has not been granted, so the caller can + * send the user to the grant screen rather than silently doing nothing. + */ + fun bindComponent(c: Context, key: HwKey, flatComponent: String): Boolean { + if (!canWrite(c)) return false + return runCatching { + if (key == HwKey.AI) { + Settings.System.putString(c.contentResolver, AI_KEY, flatComponent) + } else { + val slot = VOLUME_SLOT[key] ?: return false + Settings.System.putString(c.contentResolver, slot.shortcut, flatComponent) + Settings.System.putString(c.contentResolver, slot.key, TOKEN_APP) + } + true + }.getOrDefault(false) + } + + fun bindToken(c: Context, key: HwKey, token: String): Boolean { + val slot = VOLUME_SLOT[key] ?: return false + if (!canWrite(c)) return false + return runCatching { + Settings.System.putString(c.contentResolver, slot.key, token); true + }.getOrDefault(false) + } + + /** + * Points a firmware key at our own assist entry, which is how the Viwoods + * channel gets the full gesture engine on a device where the accessibility + * service is unavailable or unwanted. + */ + fun bindToUs(c: Context, key: HwKey): Boolean = + bindComponent(c, key, c.packageName + "/dev.equwal.assistkey.channel.AssistActivity") + + /** + * Hands a key back to the firmware: the AI key to its factory target, the + * volume keys to plain volume. + */ + fun restore(c: Context, key: HwKey): Boolean = when (key) { + HwKey.AI -> bindComponent(c, key, Presets.STOCK_AI_KEY) + HwKey.VOL_UP -> bindToken(c, key, "volume_up") + HwKey.VOL_DOWN -> bindToken(c, key, "volume_down") + HwKey.POWER -> false + } + + private fun shortName(c: Context, flat: String): String { + val cn = ComponentName.unflattenFromString(flat) ?: return flat + Presets.viwoods.firstOrNull { it.component == flat }?.let { return it.label } + return runCatching { + c.packageManager.getActivityInfo(cn, 0).loadLabel(c.packageManager).toString() + }.getOrElse { cn.shortClassName.substringAfterLast('.') } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt new file mode 100644 index 0000000..b875bae --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt @@ -0,0 +1,183 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.LinearLayout +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.GlobalAction +import dev.equwal.assistkey.model.Presets +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * Everything a gesture can be bound to, in one list. + * + * The Viwoods section is the point of parity with the stock key settings; the + * rest is what the stock screen does not offer. + */ +class ActionPickerActivity : Activity() { + + private lateinit var trigger: Trigger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + trigger = Trigger.parse(intent.getStringExtra(EXTRA_TRIGGER).orEmpty()) + ?: run { finish(); return } + build() + } + + private fun build() { + val col = Ui.page(this) + col.title(trigger.label()) + col.note("Currently: " + Store.bindings(this).raw(trigger).describe()) + + basics(col) + viwoods(col) + navigation(col) + reading(col) + soundAndMedia(col) + custom(col) + } + + private fun choose(spec: ActionSpec) { + Store.bind(this, trigger, spec) + finish() + } + + // ---- sections ---------------------------------------------------------- + + private fun basics(col: LinearLayout) { + col.header("Basics") + col.row("Default behaviour", "Let the system handle this key normally") { + choose(ActionSpec.PASS) + } + col.row("Do nothing", "Swallow the key - this is how you disable a button") { + choose(ActionSpec.NOTHING) + } + } + + private fun viwoods(col: LinearLayout) { + col.header("Viwoods AI") + Presets.viwoods.forEach { p -> + col.row(p.label, null) { choose(p.toSpec()) } + } + } + + private fun navigation(col: LinearLayout) { + col.header("Navigation") + col.note("These need the accessibility key filter to be active.") + GlobalAction.usable().forEach { g -> + col.row(g.label, null) { + choose(ActionSpec(ActionKind.GLOBAL, g.name, g.label)) + } + } + } + + private fun reading(col: LinearLayout) { + col.header("Page turning") + col.note("Synthetic swipes and scrolls, for reader apps that only take touch.") + listOf( + "left" to "Swipe left (next page)", + "right" to "Swipe right (previous page)", + "up" to "Swipe up", + "down" to "Swipe down" + ).forEach { (dir, label) -> + col.row(label, null) { choose(ActionSpec(ActionKind.SWIPE, dir, label)) } + } + listOf( + "forward" to "Scroll forward", + "backward" to "Scroll backward" + ).forEach { (dir, label) -> + col.row(label, null) { choose(ActionSpec(ActionKind.SCROLL, dir, label)) } + } + } + + private fun soundAndMedia(col: LinearLayout) { + col.header("Sound") + listOf( + "raise" to "Volume up", + "lower" to "Volume down", + "mute" to "Mute / unmute", + "panel" to "Show the volume panel" + ).forEach { (p, label) -> + col.row(label, null) { choose(ActionSpec(ActionKind.VOLUME, p, label)) } + } + + col.header("Media") + listOf( + "play_pause" to "Play / pause", + "next" to "Next track", + "previous" to "Previous track", + "stop" to "Stop" + ).forEach { (p, label) -> + col.row(label, null) { choose(ActionSpec(ActionKind.MEDIA, p, label)) } + } + } + + private fun custom(col: LinearLayout) { + col.header("Anything else") + + col.row("Open an app", "Pick from everything installed") { pickApp() } + + col.row( + "Open a specific screen", + "package/class - reaches activities with no launcher icon" + ) { + Ui.textInput(this, "Component", "com.example/com.example.SomeActivity") { v -> + choose(ActionSpec(ActionKind.LAUNCH_COMPONENT, v, shortLabel(v))) + } + } + + col.row("Send an intent action", "For example android.intent.action.VOICE_COMMAND") { + Ui.textInput(this, "Intent action", "android.intent.action.…") { v -> + choose(ActionSpec(ActionKind.LAUNCH_ACTION, v, shortLabel(v))) + } + } + + col.row("Send a broadcast", "For apps that listen for a custom action") { + Ui.textInput(this, "Broadcast action", "com.example.ACTION") { v -> + choose(ActionSpec(ActionKind.BROADCAST, v, "Broadcast " + shortLabel(v))) + } + } + } + + private fun shortLabel(v: String) = v.substringAfterLast('.').ifBlank { v } + + /** + * Only launchable apps, which is also exactly the set the manifest + * <queries> block makes visible to us. + */ + private fun pickApp() { + val pm = packageManager + val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val apps = pm.queryIntentActivities(main, 0) + .map { it.loadLabel(pm).toString() to it.activityInfo.packageName } + .distinctBy { it.second } + .sortedBy { it.first.lowercase() } + + if (apps.isEmpty()) { + Ui.textInput(this, "Package name", "com.example.app") { v -> + choose(ActionSpec(ActionKind.LAUNCH_APP, v, v)) + } + return + } + Ui.pick(this, "Open an app", apps.map { it.first }) { i -> + val (label, pkg) = apps[i] + choose(ActionSpec(ActionKind.LAUNCH_APP, pkg, label)) + } + } + + companion object { + private const val EXTRA_TRIGGER = "trigger" + + fun intent(c: Context, t: Trigger): Intent = + Intent(c, ActionPickerActivity::class.java).putExtra(EXTRA_TRIGGER, t.id) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt new file mode 100644 index 0000000..9f8445f --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt @@ -0,0 +1,82 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.widget.LinearLayout +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.check +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * Builds a key set, then hands it to the ordinary gesture list. + * + * Power is absent on purpose: it never reaches the input filter, so it can + * never be half of a chord no matter what the other key is. + */ +class ChordActivity : Activity() { + + private val picked = linkedSetOf<HwKey>() + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("Key combinations") + col.note( + "Hold one key and press the others within the chord window. Every " + + "gesture that works on a single key works on a combination too, " + + "including multi-tap and hold." + ) + + col.header("Pick the keys") + HwKey.entries.filter { it.interceptable }.forEach { key -> + col.check(key.label, null, key in picked) { on -> + if (on) picked.add(key) else picked.remove(key) + build() + } + } + + if (picked.size >= 2) { + col.button("Configure " + picked.joinToString(" + ") { it.label }) { + startActivity(TriggerListActivity.intent(this, picked.toSet())) + } + } else { + col.note("Choose at least two keys.") + } + + existing(col) + + col.header("Why no Power") + col.note( + "The power key is consumed by the system before any app can see it, " + + "so it cannot take part in a combination. Power + Volume up is " + + "reserved by the firmware for the power menu." + ) + } + + /** Anything already bound, so a chord is easy to find again. */ + private fun existing(col: LinearLayout) { + val sets = Store.bindings(this).all().keys + .filter { it.isChord } + .map { it.keys } + .distinct() + if (sets.isEmpty()) return + + col.header("Already configured") + sets.forEach { keys -> + val n = Trigger.allFor(keys).count { Store.bindings(this).isBound(it) } + col.row( + keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label }, + n.toString() + (if (n == 1) " gesture bound" else " gestures bound") + ) { startActivity(TriggerListActivity.intent(this, keys)) } + } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt new file mode 100644 index 0000000..bf9849b --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -0,0 +1,161 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.Toast +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.native.PowerNative +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.check +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * The whole configuration surface. + * + * Rebuilt in onResume rather than onCreate, because almost every setup step + * happens in another app - Settings, a role dialog - and the user comes back + * expecting the status lines to have caught up. + */ +class MainActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Channels.syncComponents(this) + } + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("AssistKey") + col.note( + "Remaps the AI key, both volume keys and the power button. " + + "Turn on the capture channels you want, then bind gestures below." + ) + channels(col) + buttons(col) + extras(col) + } + + // ---- channels ---------------------------------------------------------- + + private fun channels(col: LinearLayout) { + col.header("Capture channels") + col.note( + "Each one is a different way of getting a key routed to this app. " + + "They are independent - tick any combination." + ) + + Channel.entries.forEach { ch -> + val on = Channels.isEnabled(this, ch) + val ok = Channels.isSatisfied(this, ch) + col.check(ch.title, ch.summary, on) { checked -> + Channels.setEnabled(this, ch, checked) + if (checked && !Channels.isSatisfied(this, ch)) claim(ch) + build() + } + col.row("Status: " + Channels.status(this, ch), null, enabled = on) + if (on && !ok) col.button("Set up " + ch.title.lowercase()) { claim(ch) } + } + } + + private fun claim(ch: Channel) { + if (ch == Channel.CAMERA) { + Toast.makeText( + this, + "Choose Camera app, then pick AssistKey", + Toast.LENGTH_LONG + ).show() + } + if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) { + Toast.makeText(this, "No settings screen for that on this firmware", Toast.LENGTH_LONG) + .show() + } + } + + // ---- bindings ---------------------------------------------------------- + + private fun buttons(col: LinearLayout) { + col.header("Buttons") + + listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN).forEach { key -> + col.row(key.label, boundSummary(setOf(key))) { + startActivity(TriggerListActivity.intent(this, setOf(key))) + } + } + + col.row("Power", powerSummary()) { startActivity(Intent(this, PowerActivity::class.java)) } + + col.row( + "Two-key combinations", + "Hold one key and press another - any pair or larger set" + ) { startActivity(Intent(this, ChordActivity::class.java)) } + } + + private fun boundSummary(keys: Set<HwKey>): String { + val b = Store.bindings(this) + val bound = Trigger.allFor(keys).filter { b.isBound(it) } + if (bound.isEmpty()) return "Default behaviour" + return bound.joinToString(", ") { t -> + shortGesture(t) + " → " + b.raw(t).describe() + } + } + + private fun shortGesture(t: Trigger): String = when { + t.type == GestureType.HOLD -> "hold" + t.count == 1 -> "tap" + t.count == 2 -> "double" + t.count == 3 -> "triple" + else -> t.count.toString() + " taps" + } + + private fun powerSummary(): String { + val b = Store.bindings(this) + val parts = ArrayList<String>() + PowerNative.shortPress.firstOrNull { it.value == PowerNative.shortPressValue(this) } + ?.let { parts.add("short → " + it.label.lowercase()) } + b[Channel.POWER_DOUBLE]?.let { parts.add("double → " + it.describe()) } + b[Channel.POWER_HOLD]?.let { parts.add("hold → " + it.describe()) } + return if (parts.isEmpty()) "Default behaviour" else parts.joinToString(", ") + } + + // ---- everything else --------------------------------------------------- + + private fun extras(col: LinearLayout) { + col.header("Advanced") + + col.row( + "Firmware key hooks", + if (Channels.isEnabled(this, Channel.VIWOODS)) "Viwoods channel settings" + else "Enable the Viwoods channel to use these" + ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } + + val t = Store.timing(this) + col.row( + "Gesture timing", + "Multi-tap " + t.multiTapMs + " ms · hold " + t.holdMs + + " ms · chord " + t.chordMs + " ms" + ) { startActivity(Intent(this, TimingActivity::class.java)) } + + col.header("If you get stuck") + col.note( + "Power + Volume up opens the power menu, whatever else is bound. " + + "Uninstalling the app restores every key to firmware defaults, " + + "except the ones written through the Viwoods channel - reset " + + "those from Firmware key hooks first." + ) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt new file mode 100644 index 0000000..f4a6754 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt @@ -0,0 +1,222 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.widget.LinearLayout +import android.widget.Toast +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.native.PowerNative +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.code +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * Power is its own screen because it is not like the other keys. + * + * PhoneWindowManager consumes KEYCODE_POWER before the input dispatcher, so no + * app - accessibility service or otherwise - ever sees it. That leaves exactly + * three slots, each reached a different way: short press is firmware-only, + * double press arrives as a camera or wallet launch, and long press arrives as + * an assistant request. No multi-tap beyond two, and no chords. + */ +class PowerActivity : Activity() { + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("Power button") + col.note( + "The system eats the power key before any app can filter it, so " + + "these three slots are everything that is reachable." + ) + if (!PowerNative.canWriteSecure(this)) { + col.note( + "Short press and the firmware gesture switches below need a " + + "permission that only adb can grant. Run this once with the " + + "device plugged in:" + ) + col.code(PowerNative.GRANT_COMMAND) + } + + shortPress(col) + doublePress(col) + longPress(col) + escapeHatch(col) + } + + // ---- short press: firmware only --------------------------------------- + + private fun shortPress(col: LinearLayout) { + col.header("Short press") + val cur = PowerNative.shortPressValue(this) + val label = PowerNative.shortPress.firstOrNull { it.value == cur }?.label + ?: ("Unknown (" + cur + ")") + col.row( + "Currently: " + label, + "Handled entirely by the firmware - this rewrites its setting" + ) { + pickNative("Short press", PowerNative.shortPress, cur) { v -> + apply( + PowerNative.setShortPress(this, v), + "global", "power_button_short_press", v + ) + } + } + } + + // ---- double press: camera / wallet impersonation ----------------------- + + private fun doublePress(col: LinearLayout) { + col.header("Double press") + val camera = Channels.isSatisfied(this, Channel.CAMERA) + val wallet = Channels.isSatisfied(this, Channel.WALLET) + + col.note( + when { + camera && wallet -> "Routed here through both the camera and wallet channels." + camera -> "Routed here through the camera channel." + wallet -> "Routed here through the wallet channel." + else -> + "Nothing is routing double press here yet. Turn on the camera " + + "or wallet channel on the main screen, then pick this app " + + "as the double-press target in system settings." + } + ) + + val spec = Store.bindings(this)[Channel.POWER_DOUBLE] + col.row("Action", spec?.describe() ?: "Default behaviour") { + startActivity(ActionPickerActivity.intent(this, Channel.POWER_DOUBLE)) + } + + col.row( + "Firmware double-press gesture: " + + if (PowerNative.doubleTapGestureEnabled(this)) "on" else "off", + "Must be on for double press to do anything at all" + ) { + val on = !PowerNative.doubleTapGestureEnabled(this) + apply( + PowerNative.setDoubleTapGestureEnabled(this, on), + "secure", "double_tap_power_button_gesture_enabled", if (on) 1 else 0 + ) + } + + col.row( + "Camera double-press gesture: " + + if (PowerNative.cameraDoubleTapEnabled(this)) "on" else "off", + "The older camera-specific route, still live on this firmware" + ) { + val on = !PowerNative.cameraDoubleTapEnabled(this) + apply( + PowerNative.setCameraDoubleTapEnabled(this, on), + "secure", "camera_double_tap_power_gesture_disabled", if (on) 0 else 1 + ) + } + } + + // ---- long press: assistant impersonation ------------------------------- + + private fun longPress(col: LinearLayout) { + col.header("Press and hold") + val cur = PowerNative.longPressValue(this) + val assistant = cur == 5 + val held = Channels.isSatisfied(this, Channel.ASSISTANT) + + col.note( + when { + assistant && held -> + "Routed here: the firmware sends hold to the assistant, and that is us." + assistant -> + "The firmware sends hold to the assistant, but this app does not " + + "hold that role yet." + else -> + "The firmware does not send hold to the assistant, so nothing " + + "reaches this app." + } + ) + + val spec = Store.bindings(this)[Channel.POWER_HOLD] + col.row("Action", spec?.describe() ?: "Default behaviour") { + startActivity(ActionPickerActivity.intent(this, Channel.POWER_HOLD)) + } + + val label = PowerNative.longPress.firstOrNull { it.value == cur }?.label + ?: ("Unknown (" + cur + ")") + col.row("Firmware behaviour: " + label, "Set this to Digital assistant") { + pickNative("Press and hold", PowerNative.longPress, cur) { v -> + apply( + PowerNative.setLongPress(this, v), + "global", "power_button_long_press", v + ) + } + } + + col.row( + "Hold time: " + PowerNative.longPressMs(this) + " ms", + "How long the button must be down before it counts as a hold" + ) { + val opts = listOf(250, 350, 500, 650, 800, 1000).map { + PowerNative.Option(it, it.toString() + " ms") + } + pickNative("Hold time", opts, PowerNative.longPressMs(this)) { v -> + apply( + PowerNative.setLongPressMs(this, v), + "global", "power_button_long_press_duration_ms", v + ) + } + } + } + + private fun escapeHatch(col: LinearLayout) { + col.header("Escape hatch") + val cur = PowerNative.chordVolumeUpValue(this) + val label = PowerNative.chordVolumeUp.firstOrNull { it.value == cur }?.label + ?: ("Unknown (" + cur + ")") + col.row( + "Power + Volume up: " + label, + "Keep this on the power menu so you can always shut down" + ) { + pickNative("Power + Volume up", PowerNative.chordVolumeUp, cur) { v -> + apply( + PowerNative.setChordVolumeUp(this, v), + "global", "key_chord_power_volume_up", v + ) + } + } + } + + // ---- helpers ----------------------------------------------------------- + + private fun pickNative( + title: String, + options: List<PowerNative.Option>, + current: Int, + onPick: (Int) -> Unit + ) { + val labels = options.map { o -> + val mark = if (o.value == current) "* " else " " + mark + o.label + if (o.note.isBlank()) "" else " - " + o.note + } + Ui.pick(this, title, labels) { i -> onPick(options[i].value) } + } + + /** A refused write is the normal case without the adb grant; say so. */ + private fun apply(ok: Boolean, scope: String, key: String, value: Int) { + if (ok) { + Toast.makeText(this, "Applied", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText( + this, + "Refused - run: adb shell settings put " + scope + " " + key + " " + value, + Toast.LENGTH_LONG + ).show() + } + build() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt new file mode 100644 index 0000000..20db95b --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt @@ -0,0 +1,90 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.widget.LinearLayout +import dev.equwal.assistkey.engine.GestureEngine +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * The three windows the gesture engine runs on. Exposed because the right + * values are a matter of the user's hands, and because an e-ink device is slow + * enough that the defaults can feel wrong either way. + */ +class TimingActivity : Activity() { + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("Gesture timing") + val cfg = Store.timing(this) + + section( + col, + "Multi-tap window", + cfg.multiTapMs, + listOf(180L, 220L, 280L, 350L, 450L), + "How long to wait for another tap. Longer is easier to hit, but " + + "adds that much delay to every single tap on a key that has a " + + "double tap bound." + ) { Store.setTiming(this, cfg.copy(multiTapMs = it)) } + + section( + col, + "Hold threshold", + cfg.holdMs, + listOf(300L, 400L, 450L, 600L, 800L), + "How long a key must stay down before it counts as a hold." + ) { Store.setTiming(this, cfg.copy(holdMs = it)) } + + section( + col, + "Combination window", + cfg.chordMs, + listOf(80L, 120L, 140L, 200L, 300L), + "How close together two keys must go down to count as a combination." + ) { Store.setTiming(this, cfg.copy(chordMs = it)) } + + col.header("Defaults") + val d = GestureEngine.Config() + col.row( + "Restore " + d.multiTapMs + " / " + d.holdMs + " / " + d.chordMs + " ms", + null + ) { Store.setTiming(this, d); push(); build() } + } + + private fun section( + col: LinearLayout, + title: String, + current: Long, + options: List<Long>, + blurb: String, + onPick: (Long) -> Unit + ) { + col.header(title) + col.note(blurb) + col.row(current.toString() + " ms", "Tap to change") { + val labels = options.map { v -> + (if (v == current) "* " else " ") + v + " ms" + } + Ui.pick(this, title, labels) { i -> onPick(options[i]); push(); build() } + } + } + + /** + * The accessibility service and this screen share a process, so the running + * engine can simply be handed the new numbers instead of being restarted. + */ + private fun push() { + (ServiceHolder.service as? dev.equwal.assistkey.engine.KeyFilterService) + ?.reloadTiming() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt new file mode 100644 index 0000000..a95e504 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt @@ -0,0 +1,87 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** Every gesture available on one key or chord, with what it currently does. */ +class TriggerListActivity : Activity() { + + private lateinit var keys: Set<HwKey> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + keys = parse(intent.getStringExtra(EXTRA_KEYS)) + if (keys.isEmpty()) { finish(); return } + } + + override fun onResume() { + super.onResume() + if (keys.isEmpty()) return + build() + } + + private fun build() { + val col = Ui.page(this) + col.title(keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label }) + + if (!Channels.isEnabled(this, Channel.ACCESSIBILITY) || + !Channels.isSatisfied(this, Channel.ACCESSIBILITY) + ) { + col.note( + "The accessibility key filter is not active, so nothing here " + + "will fire yet. Turn it on from the main screen." + ) + } + + val b = Store.bindings(this) + col.header("Taps") + (1..Trigger.MAX_TAPS).forEach { n -> + val t = Trigger(keys, GestureType.TAP, n) + col.row(tapLabel(n), b.raw(t).describe()) { edit(t) } + } + + col.header("Hold") + val hold = Trigger(keys, GestureType.HOLD) + col.row("Press and hold", b.raw(hold).describe()) { edit(hold) } + + col.header("Note") + col.note( + "Binding a double tap adds a short delay to the single tap, " + + "because the app has to wait and see. Leave the higher tap " + + "counts on default behaviour if you want the key to feel instant." + ) + } + + private fun tapLabel(n: Int) = when (n) { + 1 -> "Single tap" + 2 -> "Double tap" + 3 -> "Triple tap" + else -> n.toString() + " taps" + } + + private fun edit(t: Trigger) = startActivity(ActionPickerActivity.intent(this, t)) + + companion object { + private const val EXTRA_KEYS = "keys" + + fun intent(c: Context, keys: Set<HwKey>): Intent = + Intent(c, TriggerListActivity::class.java).putExtra( + EXTRA_KEYS, keys.sortedBy { it.ordinal }.joinToString("+") { it.token } + ) + + private fun parse(s: String?): Set<HwKey> = + s?.split("+")?.mapNotNull { HwKey.fromToken(it) }?.toSet() ?: emptySet() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt new file mode 100644 index 0000000..a47a81b --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt @@ -0,0 +1,212 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.InputType +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +/** + * View construction, in code, with no support library. + * + * The screen is e-ink: no colour, slow refresh, and a hard time with subtle + * greys. So everything here is black on white at generous sizes, and nothing + * animates. + */ +object Ui { + + const val INK = Color.BLACK + val DIM = Color.rgb(90, 90, 90) + val RULE = Color.rgb(200, 200, 200) + + fun Context.dp(v: Int): Int = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics + ).toInt() + + /** Root scrolling column; returns the column so callers can keep filling it. */ + fun page(a: Activity): LinearLayout { + val col = LinearLayout(a).apply { + orientation = LinearLayout.VERTICAL + val p = a.dp(16) + setPadding(p, p, p, a.dp(40)) + } + val scroll = ScrollView(a).apply { + isFillViewport = true + setBackgroundColor(Color.WHITE) + addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } + a.setContentView(scroll) + return col + } + + fun LinearLayout.title(text: String) = add(TextView(context).apply { + this.text = text + setTextColor(INK) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) + setTypeface(Typeface.DEFAULT_BOLD) + setPadding(0, context.dp(4), 0, context.dp(8)) + }) + + fun LinearLayout.header(text: String) = add(TextView(context).apply { + this.text = text.uppercase() + setTextColor(DIM) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + setTypeface(Typeface.DEFAULT_BOLD) + letterSpacing = 0.08f + setPadding(0, context.dp(20), 0, context.dp(6)) + }) + + fun LinearLayout.note(text: String) = add(TextView(context).apply { + this.text = text + setTextColor(DIM) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, 0, 0, context.dp(8)) + }) + + fun LinearLayout.rule() = add(View(context).apply { + setBackgroundColor(RULE) + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1) + }) + + /** A tappable two-line row: what it is, and what it currently does. */ + fun LinearLayout.row( + title: String, + subtitle: String? = null, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ): LinearLayout { + val box = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val v = context.dp(12) + setPadding(0, v, 0, v) + isClickable = onClick != null && enabled + if (isClickable) { + setBackgroundResource( + android.R.drawable.list_selector_background + ) + setOnClickListener { onClick?.invoke() } + } + } + box.addView(TextView(context).apply { + text = title + setTextColor(if (enabled) INK else DIM) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f) + }) + if (!subtitle.isNullOrBlank()) { + box.addView(TextView(context).apply { + text = subtitle + setTextColor(DIM) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, context.dp(2), 0, 0) + }) + } + add(box) + rule() + return box + } + + fun LinearLayout.check( + title: String, + subtitle: String?, + checked: Boolean, + onChange: (Boolean) -> Unit + ): CheckBox { + val cb = CheckBox(context).apply { + text = title + isChecked = checked + setTextColor(INK) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f) + setPadding(context.dp(8), context.dp(10), 0, context.dp(2)) + setOnClickListener { onChange(isChecked) } + } + add(cb) + if (!subtitle.isNullOrBlank()) { + add(TextView(context).apply { + text = subtitle + setTextColor(DIM) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(context.dp(44), 0, 0, context.dp(10)) + }) + } + return cb + } + + fun LinearLayout.button(text: String, onClick: () -> Unit) = add(Button(context).apply { + this.text = text + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setOnClickListener { onClick() } + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + topMargin = context.dp(4) + bottomMargin = context.dp(8) + gravity = Gravity.START + } + }) + + /** Monospace block for adb commands the user has to run themselves. */ + fun LinearLayout.code(text: String) = add(TextView(context).apply { + this.text = text + setTextColor(INK) + setTypeface(Typeface.MONOSPACE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setBackgroundColor(Color.rgb(240, 240, 240)) + val p = context.dp(8) + setPadding(p, p, p, p) + setTextIsSelectable(true) + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + bottomMargin = context.dp(10) + } + }) + + /** Full-width by default; callers that want otherwise set params first. */ + private fun LinearLayout.add(v: View) { + if (v.layoutParams == null) { + v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + addView(v) + } + + fun pick(a: Activity, title: String, labels: List<String>, onPick: (Int) -> Unit) { + AlertDialog.Builder(a) + .setTitle(title) + .setItems(labels.toTypedArray()) { _, which -> onPick(which) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + fun textInput( + a: Activity, + title: String, + hint: String, + initial: String = "", + onOk: (String) -> Unit + ) { + val field = EditText(a).apply { + this.hint = hint + setText(initial) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + val p = a.dp(16) + setPadding(p, p, p, p) + } + AlertDialog.Builder(a) + .setTitle(title) + .setView(field) + .setPositiveButton(android.R.string.ok) { _, _ -> + val v = field.text.toString().trim() + if (v.isNotEmpty()) onOk(v) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt new file mode 100644 index 0000000..b42c58e --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt @@ -0,0 +1,127 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Intent +import android.widget.LinearLayout +import android.widget.Toast +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Presets +import dev.equwal.assistkey.native.ViwoodsBridge +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * The firmware's own key settings, edited directly. + * + * Worth having even though the accessibility channel is more capable: this one + * survives the accessibility service being killed, works on the lock screen, + * and costs nothing in latency. It just cannot do more than one action per key. + */ +class ViwoodsActivity : Activity() { + + override fun onResume() { + super.onResume() + build() + } + + private fun build() { + val col = Ui.page(this) + col.title("Firmware key hooks") + col.note( + "These are the same settings the stock key screen writes. Changing " + + "them here affects the whole system, not just this app, and they " + + "survive uninstalling it - reset them before you remove the app." + ) + + if (!ViwoodsBridge.canWrite(this)) { + col.note("This needs permission to modify system settings.") + col.button("Grant permission") { + Channels.safeStart(this, Channels.claimIntent(this, Channel.VIWOODS)) + } + return + } + + ViwoodsBridge.keys().forEach { key -> keyBlock(col, key) } + + col.header("Reset") + col.button("Restore all three keys to factory") { + var ok = true + ViwoodsBridge.keys().forEach { if (!ViwoodsBridge.restore(this, it)) ok = false } + toast(if (ok) "Restored" else "Some keys could not be written") + build() + } + } + + private fun keyBlock(col: LinearLayout, key: HwKey) { + col.header(key.label) + col.row("Currently: " + ViwoodsBridge.describe(this, key), null, enabled = false) + + col.row( + "Send to AssistKey", + "Routes this key into the gesture engine without the accessibility service" + ) { + toast(if (ViwoodsBridge.bindToUs(this, key)) "Bound" else "Write refused") + build() + } + + col.row("Viwoods AI target", "Pick one of the built-in AI screens") { + Ui.pick(this, key.label, Presets.viwoods.map { it.label }) { i -> + val p = Presets.viwoods[i] + toast( + if (ViwoodsBridge.bindComponent(this, key, p.component)) "Bound" + else "Write refused" + ) + build() + } + } + + if (key != HwKey.AI) { + col.row("Built-in behaviour", "Volume, screenshot, or open an app") { + Ui.pick(this, key.label, ViwoodsBridge.volumeTokens.map { it.second }) { i -> + val token = ViwoodsBridge.volumeTokens[i].first + if (token == ViwoodsBridge.TOKEN_APP) pickApp(key) else { + toast( + if (ViwoodsBridge.bindToken(this, key, token)) "Bound" + else "Write refused" + ) + build() + } + } + } + } + + col.row("Open an app", null) { pickApp(key) } + col.row("Restore factory value", null) { + toast(if (ViwoodsBridge.restore(this, key)) "Restored" else "Write refused") + build() + } + } + + private fun pickApp(key: HwKey) { + val pm = packageManager + val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val apps = pm.queryIntentActivities(main, 0) + .map { + it.loadLabel(pm).toString() to + (it.activityInfo.packageName + "/" + it.activityInfo.name) + } + .sortedBy { it.first.lowercase() } + + if (apps.isEmpty()) { toast("No launchable apps visible"); return } + + Ui.pick(this, "Open an app", apps.map { it.first }) { i -> + toast( + if (ViwoodsBridge.bindComponent(this, key, apps[i].second)) "Bound" + else "Write refused" + ) + build() + } + } + + private fun toast(s: String) = Toast.makeText(this, s, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..76f23d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- A hardware button: pressed ring, with tap marks either side. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#111111" + android:pathData="M54,30a24,24 0 1,0 0.01,0zM54,38a16,16 0 1,1 -0.01,0z" /> + <path + android:fillColor="#111111" + android:pathData="M50,46h8v16h-8z" /> + <path + android:fillColor="#111111" + android:pathData="M30,50h6v8h-6zM22,50h4v8h-4z" /> + <path + android:fillColor="#111111" + android:pathData="M72,50h6v8h-6zM82,50h4v8h-4z" /> +</vector> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f42ada6 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="ic_launcher_background">#FFFFFF</color> +</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4aa707..08be732 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,28 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">AssistKey</string> - <string name="service_label">AssistKey button actions</string> - <string name="service_description">Performs Back, Home, Recents and other navigation actions when triggered by the power button. No data is collected or transmitted.</string> - - <string name="heading">Long-press power → action</string> - <string name="blurb">Pick what a long press of the power button should do. The power menu stays available on Power + Volume Up.</string> - - <string name="action_back">Back</string> - <string name="action_home">Home</string> - <string name="action_recents">Recent apps</string> - <string name="action_notifications">Notification shade</string> - <string name="action_quick_settings">Quick settings</string> - <string name="action_screenshot">Screenshot</string> - <string name="action_lock">Lock screen</string> - - <string name="setup_heading">Setup — both steps are required</string> - <string name="btn_accessibility">1. Enable the accessibility service</string> - <string name="btn_assistant">2. Set AssistKey as the digital assistant</string> - <string name="btn_test">Test the selected action</string> - - <string name="status_ok">Ready</string> - <string name="status_no_service">Accessibility service is off — step 1 not done</string> - <string name="status_not_assistant">Not the default assistant — step 2 not done</string> - <string name="assistant_hint">Choose “Default digital assistant app”, then pick AssistKey.</string> - <string name="toast_no_service">Enable the accessibility service first.</string> + <string name="service_label">AssistKey key filter</string> + <string name="service_description">Reads hardware key presses so the AI key and the volume keys can be remapped, and performs navigation actions such as Back and Recents on your behalf. Nothing is collected or transmitted.</string> + <string name="wallet_label">AssistKey</string> + <string name="wallet_short">AssistKey</string> + <string name="wallet_long">AssistKey button actions</string> </resources> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 6ec913d..d79e9e5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,8 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <!-- E-ink: light, high contrast, no animation anywhere. --> + <style name="Theme.App" parent="@android:style/Theme.DeviceDefault.Light"> + <item name="android:windowAnimationStyle">@null</item> + </style> + <!-- - The assist activity must never be seen. Translucent, no title, no window - animation - otherwise you get a visible flash every time you long-press power. + The impersonation entry points must never be seen. Translucent, no + title, no window animation - otherwise there is a visible flash every + time the power button is used. --> <style name="Theme.Invisible" parent="@android:style/Theme.Translucent.NoTitleBar"> <item name="android:windowBackground">@android:color/transparent</item> diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 842cf07..b9a3582 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,13 +1,17 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - Deliberately minimal. We never inspect window content, so we do not request - canRetrieveWindowContent. The service exists purely to call performGlobalAction. + flagRequestFilterKeyEvents plus canRequestFilterKeyEvents is the whole + point: without both, onKeyEvent is never called and no key can be + intercepted. Window content is needed only for the scroll action, and + gesture dispatch only for the synthetic page-turn swipes. --> <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeWindowStateChanged" android:accessibilityFeedbackType="feedbackGeneric" - android:accessibilityFlags="flagDefault" - android:canPerformGestures="false" - android:canRetrieveWindowContent="false" + android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents|flagReportViewIds" + android:canRequestFilterKeyEvents="true" + android:canPerformGestures="true" + android:canRetrieveWindowContent="true" android:description="@string/service_description" - android:notificationTimeout="100" /> + android:notificationTimeout="100" + android:settingsActivity="dev.equwal.assistkey.ui.MainActivity" /> diff --git a/app/src/main/res/xml/quick_access_wallet.xml b/app/src/main/res/xml/quick_access_wallet.xml new file mode 100644 index 0000000..014ef1a --- /dev/null +++ b/app/src/main/res/xml/quick_access_wallet.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Required metadata for the wallet role. targetActivity is where the system + sends the user when the tile or the lock-screen button is used, which is + exactly the interception point we want. +--> +<quickaccesswallet-service xmlns:android="http://schemas.android.com/apk/res/android" + android:settingsActivity="dev.equwal.assistkey.ui.MainActivity" + android:shortcutLongLabel="@string/wallet_long" + android:shortcutShortLabel="@string/wallet_short" + android:targetActivity="dev.equwal.assistkey.channel.WalletActivity" /> diff --git a/build.gradle.kts b/build.gradle.kts index 5917b7d..cdb90a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ -// Versions are pinned here and verified against the installed SDK at build time. +// Versions are pinned so a clean clone builds the same APK. plugins { - id("com.android.application") version "8.9.0" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fec70bd --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL%