commit 4ee7a575fb8d12ea6d25b82f08f4a723d7db365c truex <truex@equwal.com> 2026-09-19 21:40:14 -0700 wip
.gitignore | 25 +++ app/build.gradle.kts | 39 +++++ app/src/main/AndroidManifest.xml | 62 +++++++ .../dev/equwal/assistkey/engine/GestureEngine.kt | 194 +++++++++++++++++++++ .../java/dev/equwal/assistkey/model/ActionSpec.kt | 79 +++++++++ .../dev/equwal/assistkey/model/GlobalAction.kt | 33 ++++ .../main/java/dev/equwal/assistkey/model/HwKey.kt | 36 ++++ .../java/dev/equwal/assistkey/model/Presets.kt | 34 ++++ .../java/dev/equwal/assistkey/model/Trigger.kt | 70 ++++++++ .../dev/equwal/assistkey/route/ActionRouter.kt | 172 ++++++++++++++++++ .../dev/equwal/assistkey/route/ServiceHolder.kt | 15 ++ .../java/dev/equwal/assistkey/store/Bindings.kt | 56 ++++++ .../main/java/dev/equwal/assistkey/store/Store.kt | 74 ++++++++ app/src/main/res/values/strings.xml | 28 +++ app/src/main/res/values/styles.xml | 14 ++ .../main/res/xml/accessibility_service_config.xml | 13 ++ build.gradle.kts | 5 + gradle.properties | 6 + settings.gradle.kts | 24 +++ 19 files changed, 979 insertions(+)
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5586b55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build output +build/ +app/build/ +.gradle/ +*.apk +*.aab +*.ap_ +*.dex + +# Local toolchain paths - machine specific +local.properties + +# IDE +.idea/ +*.iml +.vscode/ + +# Signing material must never be committed +*.jks +*.keystore +keystore.properties + +# OS +Thumbs.db +.DS_Store diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fc05836 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.equwal.assistkey" + compileSdk = 36 + + defaultConfig { + applicationId = "dev.equwal.assistkey" + // 30 is the floor for GLOBAL_ACTION_TAKE_SCREENSHOT. + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + // No shrinking: the app is tiny and R8 would only complicate + // keeping the accessibility service and assist activity reachable. + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +// Deliberately no dependencies - framework APIs only, so the APK stays a few 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 new file mode 100644 index 0000000..ededa44 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <application + android:allowBackup="true" + android:icon="@android:drawable/ic_menu_preferences" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@android:style/Theme.DeviceDefault.Light"> + + <!-- Config UI --> + <activity + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + <!-- + 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. + --> + <activity + android:name=".AssistActivity" + 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.intent.action.ASSIST" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VOICE_COMMAND" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + + <!-- Back and Recents are only reachable through global accessibility actions. --> + <service + android:name=".KeyAccessibilityService" + android:exported="false" + android:label="@string/service_label" + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> + <intent-filter> + <action android:name="android.accessibilityservice.AccessibilityService" /> + </intent-filter> + <meta-data + android:name="android.accessibilityservice" + android:resource="@xml/accessibility_service_config" /> + </service> + + </application> + +</manifest> diff --git a/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt new file mode 100644 index 0000000..20da1d7 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt @@ -0,0 +1,194 @@ +package dev.equwal.assistkey.engine + +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger + +/** + * Turns a stream of key down/up events into tap-count / hold / chord gestures. + * + * Deliberately free of Android types: everything that touches the framework + * (timers, firing an action, emulating the native key) goes through [Host], so + * the whole state machine is unit-testable on the JVM. + * + * The one rule that matters for feel: a key with no bindings at all is never + * consumed, and a key whose highest bound tap count is 1 fires on key-up + * without waiting out the multi-tap window. We only pay the multi-tap latency + * on keys where the user actually asked for a double/triple tap. + */ +class GestureEngine( + private val host: Host, + private val cfg: Config = Config() +) { + + data class Config( + /** How long to wait for another tap before committing the count. */ + val multiTapMs: Long = 280, + /** How long a key must stay down to count as a hold. */ + val holdMs: Long = 450, + /** Two keys going down within this window form a chord. */ + val chordMs: Long = 140 + ) + + interface Host { + /** Highest bound tap count for this key set, or 0 if no tap is bound. */ + fun maxTaps(keys: Set<HwKey>): Int + + /** True when a hold is bound for this key set. */ + fun hasHold(keys: Set<HwKey>): Boolean + + /** True when this exact gesture has a binding. */ + fun isBound(trigger: Trigger): Boolean + + /** Any key set that starts with this key and could still become a chord. */ + fun chordPartners(key: HwKey): Set<HwKey> + + fun fire(trigger: Trigger) + + /** Reproduce what the system would have done, since we ate the event. */ + fun passThrough(keys: Set<HwKey>, longPress: Boolean) + + fun schedule(token: String, delayMs: Long, action: () -> Unit) + fun cancel(token: String) + } + + private companion object { + const val T_HOLD = "hold" + const val T_TAP = "tap" + } + + private val down = LinkedHashSet<HwKey>() + private var seqKeys: Set<HwKey> = emptySet() + private var tapCount = 0 + private var holdFired = false + private var consuming = false + private var firstDownAt = 0L + + /** @return true to swallow the event. */ + fun onDown(key: HwKey, now: Long, repeatCount: Int): Boolean { + if (repeatCount > 0) return consuming // our own timer drives holds + + down.add(key) + + val chordUpgrade = consuming && + key !in seqKeys && + (now - firstDownAt) <= cfg.chordMs && + interesting(seqKeys + key) + + when { + chordUpgrade -> { + host.cancel(T_HOLD); host.cancel(T_TAP) + seqKeys = seqKeys + key + tapCount = 0 + holdFired = false + } + !consuming || key !in seqKeys -> { + host.cancel(T_HOLD); host.cancel(T_TAP) + seqKeys = setOf(key) + tapCount = 0 + holdFired = false + firstDownAt = now + } + else -> { + // Another tap of a sequence already in progress. + host.cancel(T_TAP) + } + } + + // Only grab the key if this set, or a chord it could grow into, is bound. + if (!interesting(seqKeys) && !couldBecomeChord(seqKeys, now)) { + reset() + return false + } + + consuming = true + if (host.hasHold(seqKeys)) { + host.schedule(T_HOLD, cfg.holdMs) { onHoldElapsed() } + } + return true + } + + /** @return true to swallow the event. */ + fun onUp(key: HwKey, now: Long): Boolean { + down.remove(key) + if (!consuming) return false + + host.cancel(T_HOLD) + + if (holdFired) { + if (down.isEmpty()) reset() + return true + } + + // For a chord, wait until every key of the chord is released. + if (down.any { it in seqKeys }) return true + + tapCount++ + val max = host.maxTaps(seqKeys) + + if (max == 0) { + // Nothing bound to taps - only a hold was. Give the key back. + val keys = seqKeys + reset() + host.passThrough(keys, longPress = false) + return true + } + + if (tapCount >= max) { + commit() + } else { + host.schedule(T_TAP, cfg.multiTapMs) { commit() } + } + return true + } + + fun onCancel() { + host.cancel(T_HOLD); host.cancel(T_TAP) + down.clear() + reset() + } + + private fun onHoldElapsed() { + val t = Trigger(seqKeys, GestureType.HOLD) + holdFired = true + tapCount = 0 + if (host.isBound(t)) host.fire(t) + } + + private fun commit() { + host.cancel(T_TAP) + val keys = seqKeys + val n = tapCount.coerceIn(1, Trigger.MAX_TAPS) + reset() + if (keys.isEmpty()) return + + val t = Trigger(keys, GestureType.TAP, n) + if (host.isBound(t)) { + host.fire(t) + } else if (n == 1) { + host.passThrough(keys, longPress = false) + } + // A 2+ tap with no binding is intentionally dropped rather than + // replayed - replaying would fire the native action N times. + } + + private fun interesting(keys: Set<HwKey>): Boolean = + host.maxTaps(keys) > 0 || host.hasHold(keys) + + /** + * A lone key with no bindings still has to be held briefly if it is the + * first half of a bound chord, otherwise the chord could never be entered. + */ + private fun couldBecomeChord(keys: Set<HwKey>, now: Long): Boolean { + if (keys.size != 1) return false + val partners = host.chordPartners(keys.first()) + return partners.isNotEmpty() && (now - firstDownAt) <= cfg.chordMs + } + + private fun reset() { + seqKeys = emptySet() + tapCount = 0 + holdFired = false + consuming = false + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt new file mode 100644 index 0000000..fdc4faa --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt @@ -0,0 +1,79 @@ +package dev.equwal.assistkey.model + +import org.json.JSONObject + +/** + * What a trigger does. [payload] is interpreted per [kind]; keeping it a plain + * string keeps persistence trivial and lets the action set grow without a + * schema migration. + */ +enum class ActionKind { + /** Swallow the key and do nothing - this is how a key gets disabled. */ + NONE, + + /** Do not intercept; let the system handle the key natively. */ + PASS_THROUGH, + + /** AccessibilityService.performGlobalAction; payload = GlobalAction.name */ + GLOBAL, + + /** payload = one of raise|lower|mute|panel */ + VOLUME, + + /** payload = one of play_pause|next|previous|stop */ + MEDIA, + + /** payload = package name; launches its main activity */ + LAUNCH_APP, + + /** payload = "pkg/class"; explicit component, no intent-filter needed */ + LAUNCH_COMPONENT, + + /** payload = intent action string; sent as an implicit activity intent */ + LAUNCH_ACTION, + + /** payload = intent action string; sent as a broadcast */ + BROADCAST, + + /** payload = up|down|left|right; dispatches a synthetic swipe */ + SWIPE, + + /** payload = forward|backward; scrolls the focused scrollable node */ + SCROLL; + + companion object { + fun fromName(n: String): ActionKind = + entries.firstOrNull { it.name == n } ?: NONE + } +} + +data class ActionSpec( + val kind: ActionKind, + val payload: String = "", + /** Cached display label so the UI need not resolve packages on every draw. */ + val label: String = "" +) { + fun toJson(): JSONObject = JSONObject() + .put("kind", kind.name) + .put("payload", payload) + .put("label", label) + + fun describe(): String = label.ifBlank { + when (kind) { + ActionKind.NONE -> "Disabled" + ActionKind.PASS_THROUGH -> "Default behaviour" + else -> if (payload.isBlank()) kind.name else "${kind.name}: $payload" + } + } + + companion object { + val NOTHING = ActionSpec(ActionKind.NONE, label = "Disabled") + val PASS = ActionSpec(ActionKind.PASS_THROUGH, label = "Default behaviour") + + fun fromJson(o: JSONObject): ActionSpec = ActionSpec( + kind = ActionKind.fromName(o.optString("kind", "NONE")), + payload = o.optString("payload", ""), + label = o.optString("label", "") + ) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt new file mode 100644 index 0000000..af542ea --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt @@ -0,0 +1,33 @@ +package dev.equwal.assistkey.model + +import android.accessibilityservice.AccessibilityService as AS +import android.os.Build + +/** + * The AccessibilityService global actions worth exposing, with the SDK level + * each one needs. Anything above the running SDK is filtered out of the picker + * rather than failing at press time. + */ +enum class GlobalAction(val id: Int, val label: String, val minSdk: Int = 30) { + BACK(AS.GLOBAL_ACTION_BACK, "Back"), + HOME(AS.GLOBAL_ACTION_HOME, "Home"), + RECENTS(AS.GLOBAL_ACTION_RECENTS, "Recent apps"), + NOTIFICATIONS(AS.GLOBAL_ACTION_NOTIFICATIONS, "Notification shade"), + QUICK_SETTINGS(AS.GLOBAL_ACTION_QUICK_SETTINGS, "Quick settings"), + POWER_DIALOG(AS.GLOBAL_ACTION_POWER_DIALOG, "Power menu"), + LOCK_SCREEN(AS.GLOBAL_ACTION_LOCK_SCREEN, "Lock screen"), + SCREENSHOT(AS.GLOBAL_ACTION_TAKE_SCREENSHOT, "Screenshot"), + DPAD_UP(16, "D-pad up", Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + DPAD_DOWN(17, "D-pad down", Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + DPAD_LEFT(18, "D-pad left", Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + DPAD_RIGHT(19, "D-pad right", Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + DPAD_CENTER(20, "D-pad centre", Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + MEDIA_PLAY_PAUSE(21, "Play / pause", Build.VERSION_CODES.UPSIDE_DOWN_CAKE); + + val available: Boolean get() = Build.VERSION.SDK_INT >= minSdk + + companion object { + fun fromName(n: String): GlobalAction? = entries.firstOrNull { it.name == n } + fun usable(): List<GlobalAction> = entries.filter { it.available } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt new file mode 100644 index 0000000..e061fc5 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt @@ -0,0 +1,36 @@ +package dev.equwal.assistkey.model + +import android.view.KeyEvent + +/** + * The physical keys this app can bind. + * + * POWER is listed because it can carry actions, but it is deliberately not + * [interceptable]: PhoneWindowManager consumes KEYCODE_POWER in + * interceptKeyBeforeQueueing() before the event reaches the input dispatcher, + * so no AccessibilityService on an unrooted device ever sees it. Power is + * driven through the role channels and the native power_button_* settings + * instead - see dev.equwal.assistkey.channel. + */ +enum class HwKey( + val code: Int, + val label: String, + /** True when an AccessibilityService can see and consume this key. */ + val interceptable: Boolean +) { + AI(KeyEvent.KEYCODE_F1, "AI key", true), + VOL_UP(KeyEvent.KEYCODE_VOLUME_UP, "Volume up", true), + VOL_DOWN(KeyEvent.KEYCODE_VOLUME_DOWN, "Volume down", true), + POWER(KeyEvent.KEYCODE_POWER, "Power", false); + + /** Stable token used in persisted trigger ids. Never change these. */ + val token: String get() = name.lowercase() + + companion object { + fun fromCode(code: Int): HwKey? = entries.firstOrNull { it.code == code } + fun fromToken(t: String): HwKey? = entries.firstOrNull { it.token == t } + + /** The keys the accessibility engine is allowed to grab. */ + val interceptable: List<HwKey> get() = entries.filter { it.interceptable } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/Presets.kt b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt new file mode 100644 index 0000000..e5f1388 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt @@ -0,0 +1,34 @@ +package dev.equwal.assistkey.model + +/** + * The stock Viwoods targets, recovered from the firmware manifests shipped on + * this device (ViwoodsLauncher 1.0.35 and viwoodsAi 2.1.52). These are what the + * OEM key settings screen binds to, so offering them here keeps parity with + * stock. + * + * ScreenCaptureActivity is the factory value of Settings.System.CustomAiKey. + */ +object Presets { + + private const val LAUNCHER = "com.viwoods.launcher" + private const val AI = "com.viwoods.viwoodsai" + + data class Preset(val label: String, val component: String) { + fun toSpec() = ActionSpec(ActionKind.LAUNCH_COMPONENT, component, label) + } + + val viwoods: List<Preset> = listOf( + Preset("AI crop (screen capture)", LAUNCHER + "/com.viwoods.libfloating.activity.ScreenCaptureActivity"), + Preset("Edit screenshot", LAUNCHER + "/com.viwoods.libfloating.activity.EditScreenshotActivity"), + Preset("AI quick prompt (transparent)", AI + "/com.wisky.wiskyai.AiTransparentActivity"), + Preset("AI lookup", AI + "/com.wisky.wiskyai.AIActivity"), + Preset("AI assistant (full UI)", AI + "/com.wisky.wiskyai.WebViewAiActivity"), + Preset("AI history", AI + "/com.wisky.wiskyai.HistroyActivity"), + Preset("AI repository", AI + "/com.wisky.wiskyai.RepositoryActivity"), + Preset("Viwoods home", LAUNCHER + "/com.viwoods.launcher.main.LauncherMainActivity") + ) + + /** The factory AI-key binding, restored when the user resets. */ + const val STOCK_AI_KEY = + "com.viwoods.launcher/com.viwoods.libfloating.activity.ScreenCaptureActivity" +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt new file mode 100644 index 0000000..1c1a6e9 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt @@ -0,0 +1,70 @@ +package dev.equwal.assistkey.model + +/** Tap (possibly repeated) or a sustained hold. */ +enum class GestureType { TAP, HOLD } + +/** + * One bindable gesture: a set of keys pressed together (one key = a plain + * press, several = a chord) performed as N taps or as a hold. + * + * Serialised as "ai+vol_up:tap:2" - keys sorted by enum ordinal so the same + * chord always produces the same id regardless of press order. + */ +data class Trigger( + val keys: Set<HwKey>, + val type: GestureType, + val count: Int = 1 +) { + init { + require(keys.isNotEmpty()) { "trigger needs at least one key" } + require(count in 1..MAX_TAPS) { "count out of range: $count" } + require(type == GestureType.TAP || count == 1) { "hold cannot repeat" } + } + + val isChord: Boolean get() = keys.size > 1 + + val id: String + get() = keys.sortedBy { it.ordinal }.joinToString("+") { it.token } + + ":" + type.name.lowercase() + ":" + count + + /** Human label, e.g. "AI key + Volume up - double tap". */ + fun label(): String { + val k = keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label } + val g = when { + type == GestureType.HOLD -> "hold" + count == 1 -> "tap" + count == 2 -> "double tap" + count == 3 -> "triple tap" + else -> "$count taps" + } + return "$k — $g" + } + + companion object { + const val MAX_TAPS = 5 + + fun parse(id: String): Trigger? { + val parts = id.split(":") + if (parts.size != 3) return null + val keys = parts[0].split("+").mapNotNull { HwKey.fromToken(it) }.toSet() + if (keys.isEmpty()) return null + val type = when (parts[1]) { + "tap" -> GestureType.TAP + "hold" -> GestureType.HOLD + else -> return null + } + val count = parts[2].toIntOrNull() ?: return null + if (count !in 1..MAX_TAPS) return null + if (type == GestureType.HOLD && count != 1) return null + return Trigger(keys, type, count) + } + + /** + * Every gesture the UI offers for a given key set: taps 1..MAX_TAPS + * plus a hold. + */ + fun allFor(keys: Set<HwKey>): List<Trigger> = + (1..MAX_TAPS).map { Trigger(keys, GestureType.TAP, it) } + + Trigger(keys, GestureType.HOLD) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt new file mode 100644 index 0000000..eb5225f --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt @@ -0,0 +1,172 @@ +package dev.equwal.assistkey.route + +import android.accessibilityservice.GestureDescription +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Path +import android.media.AudioManager +import android.util.Log +import android.view.KeyEvent +import android.view.accessibility.AccessibilityNodeInfo +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.GlobalAction + +/** Executes an [ActionSpec]. The single place any binding turns into behaviour. */ +object ActionRouter { + + private const val TAG = "AssistKey" + + fun run(ctx: Context, spec: ActionSpec): Boolean { + return try { + when (spec.kind) { + ActionKind.NONE -> true + ActionKind.PASS_THROUGH -> false + ActionKind.GLOBAL -> global(spec.payload) + ActionKind.VOLUME -> volume(ctx, spec.payload) + ActionKind.MEDIA -> media(ctx, spec.payload) + ActionKind.LAUNCH_APP -> launchApp(ctx, spec.payload) + ActionKind.LAUNCH_COMPONENT -> launchComponent(ctx, spec.payload) + ActionKind.LAUNCH_ACTION -> launchAction(ctx, spec.payload) + ActionKind.BROADCAST -> broadcast(ctx, spec.payload) + ActionKind.SWIPE -> swipe(spec.payload) + ActionKind.SCROLL -> scroll(spec.payload) + } + } catch (e: Exception) { + Log.w(TAG, "action " + spec.kind + "/" + spec.payload + " failed", e) + false + } + } + + private fun global(name: String): Boolean { + val svc = ServiceHolder.service ?: return false + val action = GlobalAction.fromName(name) ?: return false + if (!action.available) return false + return svc.performGlobalAction(action.id) + } + + private fun volume(ctx: Context, dir: String): Boolean { + val am = ctx.getSystemService(AudioManager::class.java) ?: return false + val adjust = when (dir) { + "raise" -> AudioManager.ADJUST_RAISE + "lower" -> AudioManager.ADJUST_LOWER + "mute" -> AudioManager.ADJUST_TOGGLE_MUTE + "panel" -> AudioManager.ADJUST_SAME + else -> return false + } + am.adjustSuggestedStreamVolume( + adjust, + AudioManager.USE_DEFAULT_STREAM_TYPE, + AudioManager.FLAG_SHOW_UI + ) + return true + } + + private fun media(ctx: Context, what: String): Boolean { + val code = when (what) { + "play_pause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + "next" -> KeyEvent.KEYCODE_MEDIA_NEXT + "previous" -> KeyEvent.KEYCODE_MEDIA_PREVIOUS + "stop" -> KeyEvent.KEYCODE_MEDIA_STOP + else -> return false + } + val am = ctx.getSystemService(AudioManager::class.java) ?: return false + val now = System.currentTimeMillis() + am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, code, 0)) + am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, code, 0)) + return true + } + + private fun launchApp(ctx: Context, pkg: String): Boolean { + val i = ctx.packageManager.getLaunchIntentForPackage(pkg) ?: return false + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(i) + return true + } + + /** + * An explicit component needs no intent-filter on the target, which is how + * the internal Viwoods AI activities get reached. + */ + private fun launchComponent(ctx: Context, flat: String): Boolean { + val cn = ComponentName.unflattenFromString(flat) ?: return false + val i = Intent() + i.component = cn + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(i) + return true + } + + private fun launchAction(ctx: Context, action: String): Boolean { + val i = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (i.resolveActivity(ctx.packageManager) == null) return false + ctx.startActivity(i) + return true + } + + private fun broadcast(ctx: Context, action: String): Boolean { + ctx.sendBroadcast(Intent(action)) + return true + } + + /** + * Synthetic swipe - the page-turn action for reader apps that only respond + * to touch. Uses display bounds so it scales to any screen. + */ + private fun swipe(dir: String): Boolean { + val svc = ServiceHolder.service ?: return false + val dm = svc.resources.displayMetrics + val w = dm.widthPixels.toFloat() + val h = dm.heightPixels.toFloat() + val cx = w / 2f + val cy = h / 2f + val dx = w * 0.35f + val dy = h * 0.35f + + var sx = cx; var sy = cy; var ex = cx; var ey = cy + when (dir) { + "left" -> { sx = cx + dx; ex = cx - dx } + "right" -> { sx = cx - dx; ex = cx + dx } + "up" -> { sy = cy + dy; ey = cy - dy } + "down" -> { sy = cy - dy; ey = cy + dy } + else -> return false + } + + val path = Path() + path.moveTo(sx, sy) + path.lineTo(ex, ey) + val stroke = GestureDescription.StrokeDescription(path, 0L, 120L) + return svc.dispatchGesture( + GestureDescription.Builder().addStroke(stroke).build(), null, null + ) + } + + private fun scroll(dir: String): Boolean { + val svc = ServiceHolder.service ?: return false + val action = if (dir == "backward") { + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD + } else { + AccessibilityNodeInfo.ACTION_SCROLL_FORWARD + } + val root = svc.rootInActiveWindow ?: return false + val target = findScrollable(root) ?: return false + return target.performAction(action) + } + + private fun findScrollable(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + if (node == null) return null + if (node.isScrollable) return node + for (i in 0 until node.childCount) { + val hit = findScrollable(node.getChild(i)) + if (hit != null) return hit + } + return null + } + + /** True when this action cannot run without the accessibility service. */ + fun requiresAccessibility(spec: ActionSpec): Boolean = when (spec.kind) { + ActionKind.GLOBAL, ActionKind.SWIPE, ActionKind.SCROLL -> true + else -> false + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt b/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt new file mode 100644 index 0000000..34eca74 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt @@ -0,0 +1,15 @@ +package dev.equwal.assistkey.route + +import android.accessibilityservice.AccessibilityService + +/** + * Lets any entry point (assist activity, wallet service, camera shim) reach the + * running AccessibilityService without a bind round-trip, which would add tens + * of milliseconds to something the user perceives as a button press. + */ +object ServiceHolder { + @Volatile + var service: AccessibilityService? = null + + val isRunning: Boolean get() = service != null +} diff --git a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt new file mode 100644 index 0000000..c5c2e79 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt @@ -0,0 +1,56 @@ +package dev.equwal.assistkey.store + +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger + +/** + * An immutable snapshot of every binding, with the lookups the key-event hot + * path needs precomputed. Rebuilt on save rather than queried per keystroke. + */ +class Bindings(private val map: Map<Trigger, ActionSpec>) { + + private val active: Map<Trigger, ActionSpec> = + map.filterValues { it.kind != ActionKind.PASS_THROUGH } + + private val tapsByKeys: Map<Set<HwKey>, Int> = + active.keys.filter { it.type == GestureType.TAP } + .groupBy { it.keys } + .mapValues { (_, v) -> v.maxOf { it.count } } + + private val holdKeys: Set<Set<HwKey>> = + active.keys.filter { it.type == GestureType.HOLD }.map { it.keys }.toSet() + + /** For each key, the other keys it forms a bound chord with. */ + private val partners: Map<HwKey, Set<HwKey>> = buildMap { + active.keys.filter { it.isChord }.forEach { t -> + t.keys.forEach { k -> merge(k, t.keys - k) { a, b -> a + b } } + } + } + + fun maxTaps(keys: Set<HwKey>): Int = tapsByKeys[keys] ?: 0 + fun hasHold(keys: Set<HwKey>): Boolean = keys in holdKeys + fun chordPartners(key: HwKey): Set<HwKey> = partners[key] ?: emptySet() + + operator fun get(trigger: Trigger): ActionSpec? = active[trigger] + fun isBound(trigger: Trigger): Boolean = trigger in active + + /** Including PASS_THROUGH entries, for the UI. */ + fun raw(trigger: Trigger): ActionSpec = map[trigger] ?: ActionSpec.PASS + fun all(): Map<Trigger, ActionSpec> = map + + /** Key sets the engine should watch at all. */ + fun watchedKeys(): Set<HwKey> = + (tapsByKeys.keys + holdKeys).flatten().toSet() + + fun with(trigger: Trigger, spec: ActionSpec): Bindings = + Bindings(map.toMutableMap().apply { + if (spec.kind == ActionKind.PASS_THROUGH) remove(trigger) else put(trigger, spec) + }) + + companion object { + val EMPTY = Bindings(emptyMap()) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/store/Store.kt b/app/src/main/java/dev/equwal/assistkey/store/Store.kt new file mode 100644 index 0000000..b0e53e3 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/store/Store.kt @@ -0,0 +1,74 @@ +package dev.equwal.assistkey.store + +import android.content.Context +import android.content.SharedPreferences +import dev.equwal.assistkey.engine.GestureEngine +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.Trigger +import org.json.JSONObject + +/** + * Persistence. Bindings live as one JSON blob so adding an action kind never + * needs a migration, and the parsed result is cached because the key-event + * path must not touch disk. + */ +object Store { + + private const val PREFS = "assistkey" + private const val K_BINDINGS = "bindings" + private const val K_MULTITAP = "multitap_ms" + private const val K_HOLD = "hold_ms" + private const val K_CHORD = "chord_ms" + + @Volatile private var cache: Bindings? = null + + private fun prefs(c: Context): SharedPreferences = + c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun bindings(c: Context): Bindings = cache ?: load(c).also { cache = it } + + private fun load(c: Context): Bindings { + val raw = prefs(c).getString(K_BINDINGS, null) ?: return Bindings.EMPTY + return runCatching { + val o = JSONObject(raw) + val m = HashMap<Trigger, ActionSpec>() + o.keys().forEach { id -> + val t = Trigger.parse(id) ?: return@forEach + m[t] = ActionSpec.fromJson(o.getJSONObject(id)) + } + Bindings(m) + }.getOrDefault(Bindings.EMPTY) + } + + fun save(c: Context, b: Bindings) { + val o = JSONObject() + b.all().forEach { (t, spec) -> o.put(t.id, spec.toJson()) } + prefs(c).edit().putString(K_BINDINGS, o.toString()).apply() + cache = b + } + + fun bind(c: Context, trigger: Trigger, spec: ActionSpec) { + save(c, bindings(c).with(trigger, spec)) + } + + fun timing(c: Context): GestureEngine.Config { + val p = prefs(c) + val d = GestureEngine.Config() + return GestureEngine.Config( + multiTapMs = p.getLong(K_MULTITAP, d.multiTapMs), + holdMs = p.getLong(K_HOLD, d.holdMs), + chordMs = p.getLong(K_CHORD, d.chordMs) + ) + } + + fun setTiming(c: Context, cfg: GestureEngine.Config) { + prefs(c).edit() + .putLong(K_MULTITAP, cfg.multiTapMs) + .putLong(K_HOLD, cfg.holdMs) + .putLong(K_CHORD, cfg.chordMs) + .apply() + } + + /** Forces the next read to hit disk. */ + fun invalidate() { cache = null } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f4aa707 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ +<?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> +</resources> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..6ec913d --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- + 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. + --> + <style name="Theme.Invisible" parent="@android:style/Theme.Translucent.NoTitleBar"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowAnimationStyle">@null</item> + <item name="android:windowNoTitle">true</item> + <item name="android:backgroundDimEnabled">false</item> + </style> +</resources> diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..842cf07 --- /dev/null +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,13 @@ +<?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. +--> +<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:description="@string/service_description" + android:notificationTimeout="100" /> diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5917b7d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Versions are pinned here and verified against the installed SDK at build time. +plugins { + id("com.android.application") version "8.9.0" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9d9fecd --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=false +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..aaa8535 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "AssistKey" +include(":app")