commit c94dfb74a7fa682296961c328de25acceea3bd9a truex <truex@equwal.com> 2026-09-20 21:37:55 -0700 Make the app device-generic; add extra-dim, a home screen, recents and device reports (0.0.3-alpha) Device profiles. Viwoods is now one profile rather than the premise. A profile carries only what a maker did differently: extra keys, its own key settings, a gesture switch, a brightness floor. More key types are known (page turn, camera, assistant, headset, mute, F2-F4), and one appears in the app the first time the device produces it. Viwoods-only screens and actions hide elsewhere. Extra-dim light. The reader's frontlight driver accepts 1-255 with 2047 real steps behind it, but the framework snaps anything under 5 to zero by every official route, `cmd display set-brightness` included. Writing the LED node directly gets under the floor and holds until the system slider moves. The node belongs to system, so this needs a root-level shell; the screen says so when the write is refused. Home screen. A clock, a few chosen apps and a search line; the app list stays hidden until typed for, and a single match opens itself. Original code - the idea is CLauncher's, but CLauncher is GPL-3.0-only and cannot be copied into this app. Disabled in the manifest until the user switches it on. Recent apps. A text list in place of the screenshot carousel. The real task list through the shell, with apps closable; the usage log otherwise, behind the usage-access grant. Device report. What a beta tester can send to get a device supported: model, firmware, input devices and declared keys, navigation overlays, a whitelist of key and navigation settings, the light's range. Shown in full; sent by the user through email or the share sheet. Still no INTERNET permission, so there is no automatic telemetry, by construction. Docs, listing, privacy policy and declarations updated to match.
CHANGELOG.md | 31 +++ README.md | 87 ++++++++- app/src/main/AndroidManifest.xml | 37 ++++ .../java/dev/equwal/assistkey/device/Device.kt | 94 +++++++++ .../java/dev/equwal/assistkey/display/ExtraDim.kt | 67 +++++++ .../equwal/assistkey/engine/KeyFilterService.kt | 13 ++ .../main/java/dev/equwal/assistkey/home/Apps.kt | 126 +++++++++++++ .../java/dev/equwal/assistkey/home/HomeActivity.kt | 209 +++++++++++++++++++++ .../equwal/assistkey/home/HomeSettingsActivity.kt | 114 +++++++++++ .../dev/equwal/assistkey/home/RecentsActivity.kt | 100 ++++++++++ .../main/java/dev/equwal/assistkey/model/HwKey.kt | 13 ++ .../java/dev/equwal/assistkey/native/NavNative.kt | 27 ++- .../dev/equwal/assistkey/native/ViwoodsBridge.kt | 5 +- .../java/dev/equwal/assistkey/store/Bindings.kt | 2 +- .../equwal/assistkey/ui/ActionPickerActivity.kt | 22 +++ .../dev/equwal/assistkey/ui/DisplayActivity.kt | 78 ++++++++ .../java/dev/equwal/assistkey/ui/MainActivity.kt | 32 +++- .../dev/equwal/assistkey/ui/NavigationActivity.kt | 3 +- .../java/dev/equwal/assistkey/ui/PowerActivity.kt | 4 +- .../java/dev/equwal/assistkey/ui/ReportActivity.kt | 138 ++++++++++++++ gradle.properties | 4 +- play/DECLARATIONS.md | 11 +- play/LISTING.md | 16 +- play/PRIVACY.md | 14 ++ 24 files changed, 1206 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c32b78..bda1a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.0.3-alpha - 2026-09-20 + +Version code 3. + +- **Shell access** through Shizuku, set up on the device over wireless + debugging. No computer, no root. +- **Power button, properly.** With shell access: tap, double tap, any number + of taps, hold, and true combinations. Defaults: tap Back, hold Home (closing + the keyboard first), double tap Recents, triple tap lock. The firmware is + told to do nothing on Power meanwhile, which is what stops a single tap from + swallowing a double tap, and gets the button back whenever shell access goes. +- **Navigation applies itself.** Bar, gestures and Power key in any mix, applied + in-app through the shell; adb commands only as the fallback. +- **One interface.** Assistant, camera and wallet are no longer "channels" + beside the key filter; they are the Power button's side doors for when there + is no shell access, and live on the Power screen. +- **Any Android device.** Viwoods is now one device profile. More key types; + a key appears once the device has produced it. +- **Extra-dim light**, below the system's brightness floor. +- **Home screen** and **recent apps list**, text-only, made for e-ink. +- **Device report**: what a tester can send to get a device supported. Shown in + full, sent only by the user. +- New action: Home, closing the keyboard first. + +Known limits: + +- Shizuku started over wireless debugging stops at restart; Power reverts to + the system until it is started again. +- Extra-dim needs a root-level shell. +- Purchases are still untested. + ## 0.0.2-alpha — 2026-09-20 Version code 2. diff --git a/README.md b/README.md index e83d95a..b0d608a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,78 @@ # 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. +Remaps the hardware keys of an Android device - volume keys, the Power button, +and whatever else the device has - to arbitrary actions, with multi-tap, +press-and-hold and key combinations. Also: navigation in any mix of button bar, +gestures and the Power key; a frontlight level below the system's floor; a +plain text home screen and recent-apps list made for e-ink. -Built against firmware 1.5.6 (Android 16, SDK 36). One dependency, Google's -Play Billing Library, and only because there is no other way to sell on Play. -The app holds no `INTERNET` permission. +It runs on any Android 12+ device. The **Viwoods AiPaper Reader** is the first +device profile, because that is where it was built; see *Device profiles*. Sold on Google Play as a free download with a one-time licence. Publishing, products, pricing and how the beta is run and ended are in -[play/CHECKLIST.md](play/CHECKLIST.md). +[play/CHECKLIST.md](play/CHECKLIST.md). The app holds no `INTERNET` permission. + +## Shell access + +Android keeps several things from every installed app: the Power key, the +navigation bar, system gestures, a maker's own key settings, the backlight +node. The shell user can reach them. [Shizuku](https://shizuku.rikka.app/) +(MIT API, free app) gives the app that user from the device itself, through +wireless debugging - no computer, no root. *Setup > Shell access* walks through +it. Everything that depends on it degrades honestly without it. + +| With shell access | Without | +|---|---| +| Power: any taps, hold, true combinations, read from `/dev/input` | Hold (assistant role) and double press (default camera) only | +| Bar and gestures switched in-app | The adb commands are shown | +| Recent apps: the real task list, apps can be closed | Rebuilt from the usage log | +| Extra-dim light (needs root-level shell) | Not available | + +Implementation notes that cost time: + +- Shizuku's bound *user service* cannot be used: its starter dies inside + `LoadedApk.makeApplication` on Android 16 before our class loads. `Shell` + uses Shizuku's remote-process call, and the key watcher is a pipe from + `getevent -q <node>` on the nodes that declare the key. +- While Power is managed, `power_button_short_press`, `power_button_long_press` + and the double-tap camera gesture are set to nothing; the firmware values are + saved first and restored whenever shell access, the licence, the bindings or + the service stop justifying it. Restoring needs no shell: the app grants + itself `WRITE_SECURE_SETTINGS` through the shell on first contact. The press + that wakes the screen is never a gesture; Power + Volume up stays the power + menu. + +## Device profiles + +`device/Device.kt`. A profile adds only what a maker did differently: extra +keys, its own key settings, a gesture switch, a brightness floor. Unknown +devices get the generic profile plus any supported key the key filter has +actually seen (a page-turn button appears the first time it is pressed). + +New profiles come from **device reports** (*Advanced > Device report*): model, +firmware, input devices and the keys they declare, navigation overlays, a +whitelist of key and navigation settings, the light's range. The user sees +every line and sends it themselves by email or share sheet. There is no +automatic telemetry, because there is no `INTERNET` permission; adding one is a +deliberate product decision, not a code change. + +## Extra-dim light + +On the Viwoods reader the frontlight is a backlight LED whose driver accepts +1-255 (2047 real steps), but the framework snaps anything under 5 to zero, by +every official route including `cmd display set-brightness`. Writing +`/sys/class/leds/lcd-backlight/brightness` directly gets under the floor and +sticks until the system slider is moved. The node belongs to `system`, so it +needs Shizuku running as root or a shell that may `su` (userdebug firmware). + +## Home screen and recent apps + +`home/`. The home screen is a clock, a few chosen apps and a search line; the +app list is hidden until you type, and a single match opens itself. It is +original code: the idea is shared with CLauncher/Olauncher, which are GPL-3.0 +and therefore cannot be copied into this app. Disabled in the manifest until +switched on. Recent apps is a text list in place of the screenshot carousel. ## Why it is shaped like this @@ -86,8 +148,9 @@ Android version, can see it. That leaves exactly three reachable slots: *Always*. - **Press and hold** — arrives as an assistant request. -No multi-tap beyond two. Power cannot take part in an ordinary combination, -but there is one way in: a held Power announces itself, because the firmware +That is the position **without shell access**. With it, none of this section +applies - see *Shell access*. Without it there is no multi-tap beyond two, and +Power cannot take part in an ordinary combination, but there is one way in: a held Power announces itself, because the firmware fires the assistant at us, and a key pressed while it is still down reaches the filter. So **hold Power, then press** the AI key, Volume up or Volume down is three real combinations. While any is bound, the plain hold action waits one @@ -274,7 +337,11 @@ the firmware power switches, which are ordinary system settings. ``` model/ keys, triggers, action specs, the recovered Viwoods component list engine/ the gesture state machine and the accessibility service -channel/ the four capture channels and their entry points +channel/ the key filter switch and the Power side doors (assistant, camera, wallet) +device/ device profiles +display/ the extra-dim light +home/ the home screen and the recent-apps list +shell/ shell access through Shizuku, and the managed Power button license/ licence tiers and Google Play Billing native/ firmware settings: power gestures, and the read-only key hooks route/ turning an action spec into behaviour diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e66bed8..2a09af1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,14 @@ <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" tools:ignore="ProtectedPermissions" /> + <!-- + Special access the user grants from a settings screen. Only the recent + apps list uses it, and only when there is no shell access to ask the + system directly. + --> + <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" + tools:ignore="ProtectedPermissions" /> + <!-- Required of any app that wants to be offered the wallet role. --> <uses-permission android:name="android.permission.QUICK_ACCESS_WALLET" /> @@ -61,6 +69,35 @@ <activity android:name=".ui.LicenseActivity" android:exported="false" /> <activity android:name=".ui.NavigationActivity" android:exported="false" /> <activity android:name=".ui.ShellActivity" android:exported="false" /> + <activity android:name=".ui.DisplayActivity" android:exported="false" /> + <activity android:name=".home.HomeSettingsActivity" android:exported="false" /> + <activity + android:name=".home.RecentsActivity" + android:exported="false" + android:excludeFromRecents="true" + android:launchMode="singleTask" + android:taskAffinity="dev.equwal.assistkey.recents" /> + + <!-- + The home screen. Disabled until the user asks for it, so the app is + not a launcher candidate on anybody's device by default. + --> + <activity + android:name=".home.HomeActivity" + android:enabled="false" + android:exported="true" + android:excludeFromRecents="true" + android:launchMode="singleTask" + android:stateNotNeeded="true" + android:taskAffinity="dev.equwal.assistkey.home" + android:windowSoftInputMode="stateHidden|adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.HOME" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + <activity android:name=".ui.ReportActivity" android:exported="false" /> <!-- The three impersonation entry points. Each starts disabled: the app diff --git a/app/src/main/java/dev/equwal/assistkey/device/Device.kt b/app/src/main/java/dev/equwal/assistkey/device/Device.kt new file mode 100644 index 0000000..f395775 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/device/Device.kt @@ -0,0 +1,94 @@ +package dev.equwal.assistkey.device + +import android.content.Context +import android.os.Build +import dev.equwal.assistkey.model.HwKey + +/** + * What this particular device is, and what that changes. + * + * AssistKey runs on any Android 12+ device. Most of it is the same everywhere: + * the key filter, the Power button, navigation, shell access. A profile only + * adds what a maker did differently - extra keys, its own key settings, a + * gesture switch nobody else has, a brightness floor. Viwoods is the first + * profile because it is where the app was built; an unknown device is simply + * the generic profile plus whatever keys the key tester has seen. + * + * New profiles come from the device reports beta testers send in. + */ +object Device { + + enum class Profile(val title: String) { + VIWOODS("Viwoods AiPaper"), + GENERIC("Android device") + } + + val profile: Profile by lazy { + val id = (Build.MANUFACTURER + " " + Build.BRAND + " " + Build.MODEL).lowercase() + if ("viwoods" in id || "aipaper" in id) Profile.VIWOODS else Profile.GENERIC + } + + val isViwoods: Boolean get() = profile == Profile.VIWOODS + + val name: String get() = Build.MANUFACTURER + " " + Build.MODEL + + /** Maker-specific key settings that can hide a key from every app. */ + val hasFirmwareKeyHooks: Boolean get() = isViwoods + + /** The maker's own AI screens, offered as actions. */ + val hasViwoodsActions: Boolean get() = isViwoods + + /** Settings.System switch for the maker's own swipe-up gesture, if it has one. */ + val bottomGestureSetting: String? get() = if (isViwoods) "disable_gesture_bottom" else null + + /** + * The lowest backlight value the system will set by itself. Anything under + * it gets snapped to zero by the framework, though the hardware goes lower. + * Null where it has not been measured. + */ + val brightnessFloor: Int? get() = if (isViwoods) 5 else null + + private const val PREFS = "assistkey_device" + private const val K_SEEN = "seen_keys" + + private val builtIn: List<HwKey> + get() = if (isViwoods) listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN) + else listOf(HwKey.VOL_UP, HwKey.VOL_DOWN) + + /** + * The remappable keys to show: what the profile knows about, plus any other + * supported key this device has actually produced. A page-turn button on an + * e-reader shows up here the first time it is pressed. + */ + fun keys(c: Context): List<HwKey> { + val seen = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(K_SEEN, emptySet()).orEmpty() + return (builtIn + HwKey.interceptable.filter { it.token in seen }).distinct() + } + + private const val K_UNKNOWN = "unknown_keys" + + /** + * Keys this app has no name for yet. Only recorded while the key tester is + * open, so an ordinary keyboard being typed on never lands here; and only + * as "this key exists", never as a sequence. + */ + fun noteUnknown(c: Context, keyCode: Int, scanCode: Int, device: String?) { + val p = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val seen = p.getStringSet(K_UNKNOWN, emptySet()).orEmpty() + val entry = "keycode " + keyCode + " scancode " + scanCode + " on " + (device ?: "?") + if (entry !in seen && seen.size < 40) p.edit().putStringSet(K_UNKNOWN, seen + entry).apply() + } + + fun unknownSeen(c: Context): List<String> = + c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(K_UNKNOWN, emptySet()).orEmpty().sorted() + + /** Called by the key filter for every supported key it is handed. */ + fun noteSeen(c: Context, key: HwKey) { + if (key in builtIn) return + val p = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val seen = p.getStringSet(K_SEEN, emptySet()).orEmpty() + if (key.token !in seen) p.edit().putStringSet(K_SEEN, seen + key.token).apply() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt new file mode 100644 index 0000000..52e8f17 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt @@ -0,0 +1,67 @@ +package dev.equwal.assistkey.display + +import android.content.Context +import dev.equwal.assistkey.device.Device +import dev.equwal.assistkey.shell.Shell + +/** + * Dimmer than the dimmest the system allows. + * + * Measured on the Viwoods reader: the frontlight is an ordinary backlight LED + * whose driver accepts 1..255 (and has 2047 real steps behind that), but the + * framework will not go below 5. Ask for 4 through any official route - the + * brightness setting, even the shell's `cmd display set-brightness` - and it + * snaps to zero. Writing the LED node directly gets under the floor, and the + * value then sticks until the system slider is next moved. + * + * The node belongs to `system`, so this needs more than the shell user: it + * works when Shizuku itself runs as root, or on firmware whose shell may `su` + * (userdebug builds, which is how these readers ship). Otherwise the write is + * refused and the screen says so. + */ +object ExtraDim { + + private const val PREFS = "assistkey_display" + private const val K_LEVEL = "extra_dim" + + private val nodes = listOf( + "/sys/class/leds/lcd-backlight/brightness", + "/sys/class/backlight/panel0-backlight/brightness" + ) + + /** 0 is off; otherwise the raw value to hold the light at. */ + fun level(c: Context): Int = + c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(K_LEVEL, 0) + + /** The raw values on offer: everything under the system's floor. */ + fun levels(): List<Int> = (1 until (Device.brightnessFloor ?: 5)).toList().reversed() + + fun set(c: Context, level: Int, done: (Boolean) -> Unit) { + c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(K_LEVEL, level).apply() + if (level == 0) restore(done) else write(level, done) + } + + /** Re-asserts the chosen level; called when the screen comes on. */ + fun reapply(c: Context) { + val l = level(c) + if (l > 0 && Shell.ready) write(l) {} + } + + private fun write(value: Int, done: (Boolean) -> Unit) { + // First node that exists; plain write, then through su if refused. + val script = nodes.joinToString(" ") { "'$it'" }.let { list -> + "for n in $list; do [ -e \$n ] || continue; " + + "echo $value > \$n 2>/dev/null || su 0 sh -c \"echo $value > \$n\"; " + + "[ \"\$(cat \$n)\" = \"$value\" ] && exit 0; exit 1; done; exit 2" + } + Shell.run(script) { done(it.ok) } + } + + /** Hands the light back by nudging the system's own brightness setting. */ + private fun restore(done: (Boolean) -> Unit) { + Shell.run( + "v=\$(settings get system screen_brightness); " + + "settings put system screen_brightness \$((v+1)); settings put system screen_brightness \$v" + ) { done(it.ok) } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt index c3908d1..9d0b5fd 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -61,6 +61,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { powerIgnoredUntilUp = true if (::engine.isInitialized) engine.onCancel() + dev.equwal.assistkey.display.ExtraDim.reapply(this@KeyFilterService) } } @@ -160,6 +161,9 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { override fun onKeyEvent(event: KeyEvent): Boolean { val key = HwKey.fromCode(event.keyCode) + if (key != null && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + dev.equwal.assistkey.device.Device.noteSeen(this, key) + } val consumed = when { !Channels.isEnabled(this, Channel.ACCESSIBILITY) -> false // Ahead of the licence check on purpose - see armPowerCombo. @@ -176,6 +180,11 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { // only way to find out whether a key reaches a filter on this firmware. // KeyLog drops it on the floor unless that screen is open. KeyLog.record(event, key?.label ?: ("Key " + event.keyCode), consumed) + if (key == null && KeyLog.recording && event.action == KeyEvent.ACTION_DOWN) { + dev.equwal.assistkey.device.Device.noteUnknown( + this, event.keyCode, event.scanCode, event.device?.name + ) + } return consumed } @@ -237,6 +246,10 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { ) // An unbound Power gesture does what Power always did. HwKey.POWER -> performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) + // A swallowed press cannot be replayed to the app in front. Keys + // with a system-wide meaning are handled above; the rest are lost, + // which is why a key with nothing bound is never swallowed at all. + else -> Unit } } diff --git a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt new file mode 100644 index 0000000..f58f534 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt @@ -0,0 +1,126 @@ +package dev.equwal.assistkey.home + +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Process +import dev.equwal.assistkey.shell.Shell + +/** The apps on this device, as the home screen and the recents list need them. */ +object Apps { + + data class App(val label: String, val pkg: String, val activity: String) { + val component: ComponentName get() = ComponentName(pkg, activity) + } + + private const val PREFS = "assistkey_home" + private const val K_FAVS = "favourites" // ordered, newline separated packages + private const val K_HIDDEN = "hidden" + private const val K_CLOCK = "clock" + private const val K_AUTO = "auto_open" + + private fun prefs(c: Context) = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** Everything with a launcher icon, alphabetical, ourselves included. */ + fun all(c: Context): List<App> { + val pm = c.packageManager + val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + return pm.queryIntentActivities(main, 0) + .map { App(it.loadLabel(pm).toString(), it.activityInfo.packageName, it.activityInfo.name) } + .distinctBy { it.pkg + "/" + it.activity } + .sortedBy { it.label.lowercase() } + } + + fun visible(c: Context): List<App> = hidden(c).let { h -> all(c).filter { it.pkg !in h } } + + fun launch(c: Context, app: App): Boolean = runCatching { + c.startActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + .setComponent(app.component) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + ) + true + }.getOrDefault(false) + + // ---- preferences ------------------------------------------------------------------------- + + fun favourites(c: Context): List<String> = + prefs(c).getString(K_FAVS, "").orEmpty().lines().filter { it.isNotBlank() } + + fun setFavourites(c: Context, pkgs: List<String>) { + prefs(c).edit().putString(K_FAVS, pkgs.distinct().joinToString("\n")).apply() + } + + fun toggleFavourite(c: Context, pkg: String) { + val f = favourites(c) + setFavourites(c, if (pkg in f) f - pkg else f + pkg) + } + + fun hidden(c: Context): Set<String> = prefs(c).getStringSet(K_HIDDEN, emptySet()).orEmpty() + + fun toggleHidden(c: Context, pkg: String) { + val h = hidden(c) + prefs(c).edit().putStringSet(K_HIDDEN, if (pkg in h) h - pkg else h + pkg).apply() + } + + fun showClock(c: Context): Boolean = prefs(c).getBoolean(K_CLOCK, true) + fun setShowClock(c: Context, on: Boolean) = prefs(c).edit().putBoolean(K_CLOCK, on).apply() + + /** Open the app by itself once typing has narrowed the list to one. */ + fun autoOpen(c: Context): Boolean = prefs(c).getBoolean(K_AUTO, true) + fun setAutoOpen(c: Context, on: Boolean) = prefs(c).edit().putBoolean(K_AUTO, on).apply() + + // ---- recents ----------------------------------------------------------------------------- + + fun hasUsageAccess(c: Context): Boolean { + val ops = c.getSystemService(android.app.AppOpsManager::class.java) ?: return false + return ops.unsafeCheckOpNoThrow( + android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), c.packageName + ) == android.app.AppOpsManager.MODE_ALLOWED + } + + /** Most recent first, from the usage log. Needs the user's usage-access grant. */ + fun recentFromUsage(c: Context, limit: Int = 12): List<App> { + val usm = c.getSystemService(UsageStatsManager::class.java) ?: return emptyList() + val now = System.currentTimeMillis() + val events = usm.queryEvents(now - 3L * 24 * 60 * 60 * 1000, now) + val last = LinkedHashMap<String, Long>() + val e = UsageEvents.Event() + while (events.hasNextEvent()) { + events.getNextEvent(e) + if (e.eventType == UsageEvents.Event.ACTIVITY_RESUMED) last[e.packageName] = e.timeStamp + } + return rank(c, last.entries.sortedByDescending { it.value }.map { it.key }, limit) + } + + /** Most recent first, from the system's real task list. Needs shell access. */ + fun recentFromShell(c: Context, limit: Int = 12, done: (List<App>?) -> Unit) { + if (!Shell.ready) return done(null) + Shell.run("dumpsys activity recents") { r -> + if (!r.ok) return@run done(null) + val task = Regex("""Recent #\d+: Task\{\S+ #\d+ type=(\w+) A=\d+:(\S+)""") + val pkgs = task.findAll(r.output) + .filter { it.groupValues[1] == "standard" } + .map { it.groupValues[2].trimEnd('}') } + .toList() + done(rank(c, pkgs, limit)) + } + } + + private fun rank(c: Context, pkgs: List<String>, limit: Int): List<App> { + val byPkg = all(c).associateBy { it.pkg } + val home = homePackages(c) + return pkgs.distinct() + .filter { it != c.packageName && it !in home } + .mapNotNull { byPkg[it] } + .take(limit) + } + + private fun homePackages(c: Context): Set<String> = + c.packageManager.queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY + ).map { it.activityInfo.packageName }.toSet() +} diff --git a/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt new file mode 100644 index 0000000..7f6b8d7 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt @@ -0,0 +1,209 @@ +package dev.equwal.assistkey.home + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.TypedValue +import android.view.Gravity +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.WindowInsets +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextClock +import android.widget.TextView +import dev.equwal.assistkey.ui.Ui.dp + +/** + * A home screen with nothing on it. + * + * A clock, the handful of apps you chose, and a line to type in. The full app + * list is never shown: type a few letters and the matches appear, and when only + * one is left it opens by itself. On e-ink that is also the fastest design + * there is - no icons to draw, no grid to scroll, nothing that animates. + * + * Original code. The idea of a launcher that hides everything until you type is + * shared with several open-source launchers; none of their code is here. + */ +class HomeActivity : Activity() { + + private lateinit var results: LinearLayout + private lateinit var favourites: LinearLayout + private lateinit var search: EditText + private var apps: List<Apps.App> = emptyList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + build() + } + + override fun onResume() { + super.onResume() + apps = Apps.visible(this) + search.setText("") + showFavourites() + } + + /** Home is the bottom of the stack. Back has nowhere to go. */ + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (search.text.isNotEmpty()) search.setText("") + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + // Home pressed while already home: clear the search. + if (::search.isInitialized) search.setText("") + } + + // ---- layout --------------------------------------------------------------------------------- + + private fun build() { + val col = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + val p = dp(28) + setPadding(p, dp(36), p, p) + } + + if (Apps.showClock(this)) { + col.addView(TextClock(this).apply { + format12Hour = "h:mm" + format24Hour = "H:mm" + setTextColor(Color.BLACK) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 64f) + typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) + setOnClickListener { open(Intent(android.provider.AlarmClock.ACTION_SHOW_ALARMS)) } + }) + col.addView(TextClock(this).apply { + format12Hour = "EEEE, d MMMM" + format24Hour = "EEEE, d MMMM" + setTextColor(Color.rgb(70, 70, 70)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setPadding(0, 0, 0, dp(28)) + }) + } + + favourites = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + col.addView(favourites) + + search = EditText(this).apply { + hint = "Type to find an app" + setSingleLine() + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = EditorInfo.IME_ACTION_GO + setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + setTextColor(Color.BLACK) + setPadding(0, dp(20), 0, dp(12)) + addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) = filter(s?.toString().orEmpty()) + override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) = Unit + override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) = Unit + }) + setOnEditorActionListener { _, _, _ -> matches(text.toString()).firstOrNull()?.let(::launch); true } + } + col.addView(search, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + + results = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + col.addView(results) + + val scroll = ScrollView(this).apply { + setBackgroundColor(Color.WHITE) + isFillViewport = true + clipToPadding = false + addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + setOnApplyWindowInsetsListener { v, insets -> + val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) + v.setPadding(bars.left, bars.top, bars.right, bars.bottom) + insets + } + // Long-press on empty space is the only way into settings: there is + // deliberately no button for it. + setOnLongClickListener { startActivity(Intent(this@HomeActivity, HomeSettingsActivity::class.java)); true } + } + col.setOnLongClickListener { startActivity(Intent(this, HomeSettingsActivity::class.java)); true } + setContentView(scroll) + } + + private fun line(app: Apps.App, size: Float): TextView = TextView(this).apply { + text = app.label + setTextColor(Color.BLACK) + setTextSize(TypedValue.COMPLEX_UNIT_SP, size) + gravity = Gravity.START + setPadding(0, dp(12), 0, dp(12)) + setOnClickListener { launch(app) } + setOnLongClickListener { menu(app); true } + } + + // ---- behaviour ----------------------------------------------------------------------------- + + private fun showFavourites() { + favourites.removeAllViews() + val byPkg = apps.associateBy { it.pkg } + val favs = Apps.favourites(this).mapNotNull { byPkg[it] } + favs.forEach { favourites.addView(line(it, 26f)) } + if (favs.isEmpty()) { + favourites.addView(TextView(this).apply { + text = "Find an app below, then press and hold it to keep it here." + setTextColor(Color.rgb(90, 90, 90)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + }) + } + } + + private fun matches(q: String): List<Apps.App> { + val needle = q.trim().lowercase() + if (needle.isEmpty()) return emptyList() + // Starts-with first, then anywhere in the name, then initials ("gm" finds Google Maps). + val starts = apps.filter { it.label.lowercase().startsWith(needle) } + val inside = apps.filter { needle in it.label.lowercase() } - starts.toSet() + val initials = apps.filter { a -> + a.label.split(' ', '-', '_').mapNotNull { it.firstOrNull()?.lowercaseChar() }.joinToString("").startsWith(needle) + } - starts.toSet() - inside.toSet() + return starts + inside + initials + } + + private fun filter(q: String) { + results.removeAllViews() + val found = matches(q) + if (found.size == 1 && q.trim().length >= 2 && Apps.autoOpen(this)) { + launch(found[0]) + return + } + found.take(12).forEach { results.addView(line(it, 22f)) } + } + + private fun launch(app: Apps.App) { + getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(search.windowToken, 0) + Apps.launch(this, app) + } + + private fun open(i: Intent) { + runCatching { startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + } + + private fun menu(app: Apps.App) { + val fav = app.pkg in Apps.favourites(this) + val items = arrayOf(if (fav) "Remove from home" else "Keep on home", "Hide from search", "App info") + AlertDialog.Builder(this).setTitle(app.label).setItems(items) { _, which -> + when (which) { + 0 -> Apps.toggleFavourite(this, app.pkg) + 1 -> Apps.toggleHidden(this, app.pkg) + 2 -> open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app.pkg))) + } + apps = Apps.visible(this) + search.setText("") + showFavourites() + }.show() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt new file mode 100644 index 0000000..6d35c99 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt @@ -0,0 +1,114 @@ +package dev.equwal.assistkey.home + +import android.app.Activity +import android.app.role.RoleManager +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import dev.equwal.assistkey.ui.Ui +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 + +/** + * Switching the AssistKey home screen on, and tuning it. + * + * The home activity is disabled in the manifest. Until the user asks for it + * here the app is not a launcher and never shows up as one. + */ +class HomeSettingsActivity : Activity() { + + private val home by lazy { ComponentName(this, HomeActivity::class.java) } + + override fun onResume() { + super.onResume() + build() + } + + private fun enabled(): Boolean = + packageManager.getComponentEnabledSetting(home) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED + + private fun isDefault(): Boolean { + val res = packageManager.resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY + ) + return res?.activityInfo?.packageName == packageName + } + + private fun build() { + val col = Ui.page(this) + col.title("Home screen") + col.note( + "A home screen with nothing on it: a clock, the few apps you choose, and " + + "a line to type in. Apps stay out of sight until you type, and when " + + "one match is left it opens. No icons, no grid, nothing to redraw." + ) + + col.check("Offer AssistKey as a home screen", null, enabled()) { on -> + packageManager.setComponentEnabledSetting( + home, + if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + build() + } + if (!enabled()) return + + col.row( + "Status: " + if (isDefault()) "This is your home screen" else "Not your home screen yet", + null, enabled = false + ) + if (!isDefault()) { + col.button("Make it the home screen") { + val rm = getSystemService(RoleManager::class.java) + val i = if (rm != null && rm.isRoleAvailable(RoleManager.ROLE_HOME)) { + rm.createRequestRoleIntent(RoleManager.ROLE_HOME) + } else Intent(Settings.ACTION_HOME_SETTINGS) + runCatching { startActivityForResult(i, 1) } + .onFailure { runCatching { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) } } + } + } else { + col.button("Choose a different home screen") { + runCatching { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) } + } + } + col.button("Open it") { startActivity(Intent(this, HomeActivity::class.java)) } + + col.header("Options") + col.check("Clock and date", null, Apps.showClock(this)) { Apps.setShowClock(this, it) } + col.check("Open the app when one match is left", "After at least two letters", Apps.autoOpen(this)) { + Apps.setAutoOpen(this, it) + } + + col.header("On the home screen") + val byPkg = Apps.all(this).associateBy { it.pkg } + val favs = Apps.favourites(this).mapNotNull { byPkg[it] } + if (favs.isEmpty()) col.note("Nothing yet.") + favs.forEach { app -> + col.row(app.label, "Tap to remove") { Apps.toggleFavourite(this, app.pkg); build() } + } + col.button("Add an app") { + val all = Apps.all(this).filter { it.pkg !in Apps.favourites(this) } + Ui.pick(this, "Keep on home", all.map { it.label }) { i -> + Apps.toggleFavourite(this, all[i].pkg); build() + } + } + + val hidden = Apps.hidden(this).mapNotNull { byPkg[it] } + if (hidden.isNotEmpty()) { + col.header("Hidden from search") + hidden.forEach { app -> + col.row(app.label, "Tap to show again") { Apps.toggleHidden(this, app.pkg); build() } + } + } + col.note( + "On the home screen itself, press and hold an app for its options, or " + + "empty space to come back here." + ) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt new file mode 100644 index 0000000..fc0b8b1 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt @@ -0,0 +1,100 @@ +package dev.equwal.assistkey.home + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.LinearLayout +import dev.equwal.assistkey.shell.Shell +import dev.equwal.assistkey.ui.ShellActivity +import dev.equwal.assistkey.ui.Ui +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 + +/** + * Recent apps, as a list. + * + * The stock switcher is a carousel of screenshots - the worst thing an e-ink + * panel can be asked to draw. This is the same information as text: most recent + * first, one tap to go back to it. + * + * Android does not tell apps what is running. With shell access the list is the + * real task list of the system, and an app can be closed from it. Without, it + * is rebuilt from the usage log, which needs the usage-access grant. + * + * Bind it to a key from any action list: Navigation > Recent apps (list). + */ +class RecentsActivity : Activity() { + + override fun onResume() { + super.onResume() + load() + } + + private fun load() { + Apps.recentFromShell(this) { fromShell -> + if (isFinishing) return@recentFromShell + when { + fromShell != null -> show(fromShell, closable = true) + Apps.hasUsageAccess(this) -> show(Apps.recentFromUsage(this), closable = false) + else -> askForAccess() + } + } + } + + private fun show(apps: List<Apps.App>, closable: Boolean) { + val col = Ui.page(this) + col.title("Recent apps") + if (apps.isEmpty()) col.note("Nothing recent.") + apps.forEach { app -> entry(col, app, closable) } + if (closable && apps.isNotEmpty()) { + col.header("All of them") + col.button("Close all") { + Shell.runAll(apps.map { "am force-stop " + it.pkg }) { load() } + } + col.note("Press and hold an app to close just that one.") + } + if (!closable) col.note("With shell access this list is exact, and apps can be closed from it.") + } + + private fun entry(col: LinearLayout, app: Apps.App, closable: Boolean) { + val row = col.row(app.label, null) { + Apps.launch(this, app) + finish() + } + if (!closable) return + row.setOnLongClickListener { + AlertDialog.Builder(this).setTitle(app.label) + .setItems(arrayOf("Close", "App info")) { _, which -> + if (which == 0) { + Shell.run("am force-stop " + app.pkg) { load() } + } else { + runCatching { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app.pkg)) + ) + } + } + }.show() + true + } + } + + private fun askForAccess() { + val col = Ui.page(this) + col.title("Recent apps") + col.note( + "Android does not tell apps what has been used recently. Either give " + + "AssistKey usage access, or turn on shell access, which gives the " + + "exact list the system keeps." + ) + col.button("Give usage access") { + runCatching { startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) } + } + col.button("Shell access") { startActivity(Intent(this, ShellActivity::class.java)) } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt index e061fc5..320fc93 100644 --- a/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt +++ b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt @@ -21,6 +21,19 @@ enum class HwKey( 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), + + // Keys other devices have. They appear in the app once the device has + // actually produced one, so nobody is shown a Page up key they do not own. + PAGE_UP(KeyEvent.KEYCODE_PAGE_UP, "Page up", true), + PAGE_DOWN(KeyEvent.KEYCODE_PAGE_DOWN, "Page down", true), + CAMERA(KeyEvent.KEYCODE_CAMERA, "Camera key", true), + FOCUS(KeyEvent.KEYCODE_FOCUS, "Camera half-press", true), + ASSIST(KeyEvent.KEYCODE_ASSIST, "Assistant key", true), + HEADSET(KeyEvent.KEYCODE_HEADSETHOOK, "Headset button", true), + MUTE(KeyEvent.KEYCODE_VOLUME_MUTE, "Mute key", true), + F2(KeyEvent.KEYCODE_F2, "F2 key", true), + F3(KeyEvent.KEYCODE_F3, "F3 key", true), + F4(KeyEvent.KEYCODE_F4, "F4 key", true), POWER(KeyEvent.KEYCODE_POWER, "Power", false); /** Stable token used in persisted trigger ids. Never change these. */ diff --git a/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt index 04ac77e..9dd71f1 100644 --- a/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt +++ b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt @@ -42,7 +42,12 @@ object NavNative { /** True if the swipe-up gesture is live, null if the setting cannot be read. */ fun gesturesOn(c: Context): Boolean? { - val v = runCatching { Settings.System.getString(c.contentResolver, GESTURE_BOTTOM) } + val name = dev.equwal.assistkey.device.Device.bottomGestureSetting + if (name == null) { + // Stock Android: gestures exist exactly when the bar does not. + return null + } + val v = runCatching { Settings.System.getString(c.contentResolver, name) } .getOrElse { return null } return v?.trim() != "1" } @@ -59,15 +64,17 @@ object NavNative { // Only meaningful without the bar, but harmless with it, and setting it // every time means a later switch to gestural mode cannot surprise. val scale = if (gestures) "0.6" else "0" - return listOf( - "cmd overlay enable-exclusive --category " + OVERLAY + (if (buttons) "threebutton" else "gestural"), - "sleep 4", - "settings put system $GESTURE_BOTTOM $nudge", - "sleep 1", - "settings put system $GESTURE_BOTTOM $off", - "settings put secure back_gesture_inset_scale_left $scale", - "settings put secure back_gesture_inset_scale_right $scale" - ) + val out = ArrayList<String>() + out += "cmd overlay enable-exclusive --category " + OVERLAY + (if (buttons) "threebutton" else "gestural") + if (dev.equwal.assistkey.device.Device.bottomGestureSetting != null) { + out += "sleep 4" + out += "settings put system $GESTURE_BOTTOM $nudge" + out += "sleep 1" + out += "settings put system $GESTURE_BOTTOM $off" + } + out += "settings put secure back_gesture_inset_scale_left $scale" + out += "settings put secure back_gesture_inset_scale_right $scale" + return out } /** The same, for someone typing them at a computer. */ diff --git a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt index b9b1945..27e5c1d 100644 --- a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt +++ b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt @@ -74,7 +74,8 @@ object ViwoodsBridge { * made for it in this app can never fire. */ fun hidesFromFilter(c: Context, key: HwKey): Boolean = - VOLUME_SLOT.containsKey(key) && read(c, key) != null + dev.equwal.assistkey.device.Device.hasFirmwareKeyHooks && + VOLUME_SLOT.containsKey(key) && read(c, key) != null private const val AI_ENTRY = "dev.equwal.assistkey.channel.AiKeyActivity" @@ -87,7 +88,7 @@ object ViwoodsBridge { /** True when the firmware sends the AI key straight to this app. */ fun aiHookedToUs(c: Context): Boolean = - read(c, HwKey.AI)?.let { ComponentName.unflattenFromString(it) } == + dev.equwal.assistkey.device.Device.hasFirmwareKeyHooks && read(c, HwKey.AI)?.let { ComponentName.unflattenFromString(it) } == ComponentName(c.packageName, AI_ENTRY) fun aiHookCommand(c: Context): String = diff --git a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt index ae689d9..2c01df8 100644 --- a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt +++ b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt @@ -57,7 +57,7 @@ class Bindings(private val map: Map<Trigger, ActionSpec>) { fun powerCombo(key: HwKey): ActionSpec? = active[Trigger.powerThen(key)] val hasPowerCombos: Boolean = - HwKey.interceptable.any { active.containsKey(Trigger.powerThen(it)) } + active.keys.any { it.keys.size == 2 && HwKey.POWER in it.keys } /** Including PASS_THROUGH entries, for the UI. */ fun raw(trigger: Trigger): ActionSpec = map[trigger] ?: ActionSpec.PASS diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt index b875bae..d2e1785 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt @@ -41,6 +41,7 @@ class ActionPickerActivity : Activity() { basics(col) viwoods(col) navigation(col) + own(col) reading(col) soundAndMedia(col) custom(col) @@ -64,12 +65,33 @@ class ActionPickerActivity : Activity() { } private fun viwoods(col: LinearLayout) { + if (!dev.equwal.assistkey.device.Device.hasViwoodsActions) return col.header("Viwoods AI") Presets.viwoods.forEach { p -> col.row(p.label, null) { choose(p.toSpec()) } } } + private fun own(col: LinearLayout) { + col.header("AssistKey") + listOf( + "Recent apps (list)" to "dev.equwal.assistkey.home.RecentsActivity", + "App search" to "dev.equwal.assistkey.home.HomeActivity" + ).filter { (_, cls) -> + // The home screen is switched off until asked for; a disabled + // activity cannot be launched, so it is not offered. + packageManager.getComponentEnabledSetting(android.content.ComponentName(packageName, cls)) != + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED && + (cls.endsWith("RecentsActivity") || + packageManager.getComponentEnabledSetting(android.content.ComponentName(packageName, cls)) == + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED) + }.forEach { (label, cls) -> + col.row(label, null) { + choose(ActionSpec(ActionKind.LAUNCH_COMPONENT, packageName + "/" + cls, label)) + } + } + } + private fun navigation(col: LinearLayout) { col.header("Navigation") col.note("These need the accessibility key filter to be active.") diff --git a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt new file mode 100644 index 0000000..7b49483 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt @@ -0,0 +1,78 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Intent +import android.widget.Toast +import dev.equwal.assistkey.device.Device +import dev.equwal.assistkey.display.ExtraDim +import dev.equwal.assistkey.shell.Shell +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 frontlight, below where the system's own slider stops. */ +class DisplayActivity : Activity() { + + private val redraw: () -> Unit = { if (!isFinishing) build() } + + override fun onResume() { + super.onResume() + Shell.onChange(redraw) + Shell.connect(this) + build() + } + + override fun onPause() { + super.onPause() + Shell.removeOnChange(redraw) + } + + private fun build() { + val col = Ui.page(this) + col.title("Extra-dim light") + col.note( + "The system will not set the light below a fixed floor" + + (Device.brightnessFloor?.let { " (" + it + " of 255 on this device)" } ?: "") + + ", though the hardware goes lower. These levels sit under that floor, " + + "for reading in the dark." + ) + + if (!Shell.ready) { + col.row("Shell access: " + Shell.describe(this), "Needed to reach the light directly") { + startActivity(Intent(this, ShellActivity::class.java)) + } + return + } + + val current = ExtraDim.level(this) + col.header("Level") + col.row((if (current == 0) "* " else " ") + "Off", "The system slider is in charge") { choose(0) } + ExtraDim.levels().forEach { v -> + col.row((if (current == v) "* " else " ") + v + " of 255", null) { choose(v) } + } + col.note( + "The level holds until the system's brightness slider is moved, and is " + + "put back whenever the screen comes on. If this device does not let " + + "the shell reach the light, choosing a level will say so." + ) + if (Device.brightnessFloor == null) { + col.note("This device's floor has not been measured, so these values are a guess.") + } + } + + private fun choose(level: Int) { + ExtraDim.set(this, level) { ok -> + if (!ok) { + Toast.makeText( + this, + "This device would not let the shell change the light. It needs " + + "Shizuku started as root, or firmware that allows su.", + Toast.LENGTH_LONG + ).show() + ExtraDim.set(this, 0) {} + } + build() + } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt index e3bb6bb..c463a3b 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -170,7 +170,7 @@ class MainActivity : Activity() { private fun buttons(col: LinearLayout) { col.header("Buttons") - listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN).forEach { key -> + dev.equwal.assistkey.device.Device.keys(this).forEach { key -> val summary = if (ViwoodsBridge.hidesFromFilter(this, key)) { "Hidden by a firmware hook - see Firmware key hooks below" @@ -224,15 +224,33 @@ class MainActivity : Activity() { // ---- everything else --------------------------------------------------- private fun extras(col: LinearLayout) { + col.header("Display and home") + col.row("Extra-dim light", "Below the lowest the system slider allows") { + startActivity(Intent(this, DisplayActivity::class.java)) + } + col.row("Home screen", "A plain, fast launcher made for e-ink") { + startActivity(Intent(this, dev.equwal.assistkey.home.HomeSettingsActivity::class.java)) + } + col.row("Recent apps", "A plain app switcher, bindable to any key") { + startActivity(Intent(this, dev.equwal.assistkey.home.RecentsActivity::class.java)) + } + col.header("Advanced") - val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) } + if (dev.equwal.assistkey.device.Device.hasFirmwareKeyHooks) { + val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) } + col.row( + "Firmware key hooks", + if (hidden.isEmpty()) "What the firmware itself does with each key" + else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } + + " from this app - tap for the fix" + ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } + } + col.row( - "Firmware key hooks", - if (hidden.isEmpty()) "What the firmware itself does with each key" - else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } + - " from this app - tap for the fix" - ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } + "Device report", + "Help get this device fully supported - you see everything before it is sent" + ) { startActivity(Intent(this, ReportActivity::class.java)) } col.row( "Key tester", diff --git a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt index 7d91dff..956ec3d 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt @@ -141,7 +141,8 @@ class NavigationActivity : Activity() { redrawnForInsets = true window.decorView.post { if (!isFinishing) build() } // insets need an attached window } - val gesturesNow = NavNative.gesturesOn(this) + // Stock Android has no separate gesture switch: gestures are on exactly when the bar is off. + val gesturesNow = NavNative.gesturesOn(this) ?: buttonsNow?.not() val wantButtons = wanted("buttons", buttonsNow ?: true) val wantGestures = wanted("gestures", gesturesNow ?: true) diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt index c903bce..c970d64 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt @@ -92,7 +92,7 @@ class PowerActivity : Activity() { bindRow(col, "Press and hold", Trigger(power, GestureType.HOLD)) col.header("Power held, plus another key") - HwKey.interceptable.forEach { key -> + dev.equwal.assistkey.device.Device.keys(this).forEach { key -> bindRow(col, "Power + " + key.label, Trigger.powerThen(key)) } @@ -140,7 +140,7 @@ class PowerActivity : Activity() { col.header("Hold - through the assistant door") door(col, Channel.ASSISTANT) bindRow(col, "Press and hold", Channel.POWER_HOLD) - HwKey.interceptable.forEach { key -> + dev.equwal.assistkey.device.Device.keys(this).forEach { key -> bindRow(col, "Power held, then " + Ui.inSentence(key), Trigger.powerThen(key)) } col.note( diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt new file mode 100644 index 0000000..d92fabb --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt @@ -0,0 +1,138 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import dev.equwal.assistkey.BuildConfig +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.device.Device +import dev.equwal.assistkey.native.NavNative +import dev.equwal.assistkey.shell.PowerControl +import dev.equwal.assistkey.shell.Shell +import dev.equwal.assistkey.ui.Ui.button +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.title + +/** + * A description of this device, for getting it supported. + * + * Every device maker wires keys, gestures and lights differently, and the only + * way to support one is to know what it has. This screen gathers that - model, + * firmware, the input devices and the keys they declare, the navigation + * overlays, a handful of key-related settings - shows every line of it, and + * sends it nowhere. The user sends it, by email or however they like, if they + * choose to. The app has no internet permission and could not send it itself. + * + * What is deliberately not in it: installed apps, accounts, identifiers, the + * contents of anything, and any key that was actually pressed outside the key + * tester. + */ +class ReportActivity : Activity() { + + private var report = "" + private val to = "truex@equwal.com" + + override fun onResume() { + super.onResume() + build("Gathering...") + gather { text -> + report = text + if (!isFinishing) build(text) + } + } + + private fun build(text: String) { + val col = Ui.page(this) + col.title("Device report") + col.note( + "Help get " + Device.name + " fully supported. This is everything the " + + "report contains - nothing is sent unless you send it, and AssistKey " + + "has no internet permission to send it with." + ) + col.button("Send by email") { + val i = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + to)) + .putExtra(Intent.EXTRA_SUBJECT, "AssistKey device report: " + Device.name) + .putExtra(Intent.EXTRA_TEXT, report) + if (runCatching { startActivity(i) }.isFailure) share() + } + col.button("Share another way") { share() } + col.button("Copy") { + getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip(ClipData.newPlainText("AssistKey device report", report)) + Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() + } + col.header("The report") + col.code(text) + if (!Shell.ready) { + col.note("With shell access the report also lists the input devices and their keys, which is the most useful part.") + } + } + + private fun share() { + runCatching { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, "AssistKey device report: " + Device.name) + .putExtra(Intent.EXTRA_TEXT, report), + "Send report" + ) + ) + } + } + + // ---- gathering ------------------------------------------------------------------------------ + + private fun gather(done: (String) -> Unit) { + val sb = StringBuilder() + fun line(k: String, v: Any?) { sb.append(k).append(": ").append(v).append('\n') } + + sb.append("# AssistKey device report\n") + line("app", BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")") + line("manufacturer", Build.MANUFACTURER) + line("brand", Build.BRAND) + line("model", Build.MODEL) + line("device", Build.DEVICE) + line("product", Build.PRODUCT) + line("android", Build.VERSION.RELEASE + " (SDK " + Build.VERSION.SDK_INT + ")") + line("firmware", Build.DISPLAY) + line("build type", Build.TYPE) + line("profile", Device.profile) + val dm = resources.displayMetrics + line("screen", dm.widthPixels.toString() + " x " + dm.heightPixels + " @ " + dm.densityDpi + " dpi") + + sb.append("\n# State\n") + line("key filter", Channels.status(this, Channel.ACCESSIBILITY)) + line("shell access", Shell.state(this)) + line("power button managed", PowerControl.active) + line("button bar showing", NavNative.buttonsShowing(this)) + line("keys shown", Device.keys(this).joinToString { it.token }) + line("keys seen in tester", Device.unknownSeen(this).ifEmpty { listOf("none") }.joinToString("; ")) + + if (!Shell.ready) return done(sb.toString()) + + // Names and shapes only. The settings query is a whitelist of key and + // navigation switches; per-app settings, which would name installed + // apps, are left out on purpose. + val wanted = "custom.*key|power_button|key_chord|navigation_mode|disable_gesture_bottom|" + + "double_tap_power|camera_double_tap|screen_brightness\$|back_gesture_inset" + val script = listOf( + "echo '# Input devices'; getevent -p 2>/dev/null | grep -E 'add device|name:|KEY \\(0001\\)|^ {20}[0-9a-f]' | head -80", + "echo; echo '# Navigation overlays'; cmd overlay list 2>/dev/null | grep -i navbar", + "echo; echo '# Settings'; for t in system secure global; do settings list \$t 2>/dev/null | grep -iE '$wanted' | grep -v 'disable_gesture_[a-z]*\\.' | sed \"s/^/[\$t] /\"; done", + "echo; echo '# Light'; for n in /sys/class/leds/lcd-backlight /sys/class/backlight/*; do [ -e \$n/brightness ] && echo \$n now=\$(cat \$n/brightness) max=\$(cat \$n/max_brightness 2>/dev/null) min=\$(cat \$n/min_brightness 2>/dev/null); done", + "echo; echo '# Shell'; id | cut -d' ' -f1" + ).joinToString("; ") + Shell.run(script) { r -> + sb.append('\n').append(r.output.trim()).append('\n') + done(sb.toString()) + } + } +} diff --git a/gradle.properties b/gradle.properties index e87ddb4..c59f674 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,8 +6,8 @@ android.nonTransitiveRClass=true kotlin.code.style=official # Release identity. versionCode must rise with every upload to Play. -assistkey.versionName=0.0.2-alpha -assistkey.versionCode=2 +assistkey.versionName=0.0.3-alpha +assistkey.versionCode=3 # The free beta ends on this date (UTC) at the latest. It is the backstop for # installs that cannot reach Google Play; the real switch is the diff --git a/play/DECLARATIONS.md b/play/DECLARATIONS.md index cff03ba..995573e 100644 --- a/play/DECLARATIONS.md +++ b/play/DECLARATIONS.md @@ -1,7 +1,7 @@ # App content declarations Answers for Play Console > Policy and programs > App content. Each is true of -the build tagged `v0.0.1-alpha`; re-check them if the app changes. +the build tagged `v0.0.3-alpha`; re-check them if the app changes. ## Privacy policy @@ -43,8 +43,11 @@ so it is not a financial feature.) | Do you provide a way for users to request that their data is deleted? | Not applicable | Why "No" is accurate: the app holds no `INTERNET` permission -(`aapt2 dump badging` on the release shows only `WRITE_SECURE_SETTINGS`, -`QUICK_ACCESS_WALLET` and `com.android.vending.BILLING`). Data that never +(`aapt2 dump badging` on the release shows `WRITE_SECURE_SETTINGS`, +`PACKAGE_USAGE_STATS`, `QUICK_ACCESS_WALLET`, `com.android.vending.BILLING` and +Shizuku's `API_V23`, none of which is network access). The device report is +composed on the device and leaves it only if the user sends it, through their +own email or share target. Data that never leaves the device is not "collected" in Play's sense. Purchase handling by Google Play's own billing system does not have to be declared by the app. @@ -82,6 +85,8 @@ pointed at the reader - the reader cannot capture its own screen. |---|---| | `BIND_ACCESSIBILITY_SERVICE` | Above. | | `WRITE_SECURE_SETTINGS` | Cannot be granted to a Play install; it does nothing unless the owner grants it over adb. It then lets the app switch the firmware's power-button behaviour (short press, hold duration). Declared so that the grant is possible at all. | +| `PACKAGE_USAGE_STATS` | Special access the user grants from a settings screen. Used only by the recent-apps list to order apps by last use, on the device; nothing is stored or sent. Not needed when shell access is on. | +| `moe.shizuku.manager.permission.API_V23` | Lets the user connect AssistKey to Shizuku, a separate app they install and start themselves, for features that need the shell user. | | `QUICK_ACCESS_WALLET` | Required of any app offered as the wallet app. AssistKey serves an empty card list; it exists so a press of the wallet shortcut reaches the user's chosen action. | | `com.android.vending.BILLING` | Play Billing. | diff --git a/play/LISTING.md b/play/LISTING.md index 04cc6a5..e0b6e78 100644 --- a/play/LISTING.md +++ b/play/LISTING.md @@ -11,15 +11,17 @@ AssistKey: Key Remapper ## Short description (80 max) ``` -Remap the AI key, volume keys and power button on your Viwoods AiPaper. +Remap volume keys, the power button and more. Made for e-ink readers. ``` ## Full description (4000 max) ``` -AssistKey remaps the hardware keys on the Viwoods AiPaper Reader: the AI key, both volume keys, and the power button. +AssistKey remaps your device's hardware keys: volume keys, the power button, page-turn buttons, camera and assistant keys, and on the Viwoods AiPaper the AI key. -Turn pages with the volume keys. Make the AI key go Back, or Home, or open your reading app. Put three different actions on one key with a tap, a double tap and a hold. Hold the power button to launch anything you like. +Turn pages with the volume keys. Tap the power button for Back, hold it for Home, double tap it for Recents - and hide the navigation bar for good. Put three different actions on one key with a tap, a double tap and a hold. + +Built for e-ink readers, and it works on any Android 12+ phone or tablet. WHAT EACH KEY CAN DO @@ -45,6 +47,14 @@ ACTIONS • Send an intent or a broadcast, for automation apps • Do nothing - disable a key you keep pressing by accident +MORE FOR E-INK +• Extra-dim light: go below the lowest brightness the system allows (needs shell access with root) +• A plain text home screen: a clock, a few apps, type to find the rest +• Recent apps as a list instead of a slow screenshot carousel + +SHELL ACCESS (OPTIONAL) +Some things Android keeps from every app: the power button, the navigation bar, system gestures. AssistKey can use the free Shizuku app to reach them, set up on the device itself through wireless debugging - no computer, no root. Without it AssistKey still remaps keys, and the power button still gets hold and double press. + BUILT FOR E-INK Black on white, large text, no animation. No ads. No account. diff --git a/play/PRIVACY.md b/play/PRIVACY.md index dd856c7..7dda86d 100644 --- a/play/PRIVACY.md +++ b/play/PRIVACY.md @@ -26,6 +26,20 @@ can scroll it. It does not read, record or transmit what the window contains. channels are on are saved in the app's private storage on your device. They leave the device only through Android's own backup, if you have it on. +**Recent apps.** If you use the recent-apps list without shell access, it +reads the usage log Android keeps, with the usage access you grant, to put apps +in order of last use. This happens on the device each time the list opens; +nothing from it is stored or sent. + +**Shell access.** If you connect AssistKey to Shizuku, it runs commands on your +device as the shell user to read the Power button, switch the navigation bar +and gestures, and set the light. These act on your device only. + +**Device report.** The report you can create under Advanced lists your device +model, firmware, input devices, navigation options and a short list of key and +navigation settings. It does not include your apps, accounts or identifiers. +It is shown to you in full and goes nowhere unless you send it yourself. + **Licence state.** The app saves, on your device, the date it was first opened, whether it was used during the beta, and which AssistKey products Google Play reports your account as owning.