commit c85a2296cf5f388a7ee502f68547f65f2626edf8 truex <truex@equwal.com> 2026-09-21 02:16:02 -0700 Put the device drawing on the main screen and rename the app to Rebind The main screen shows the device and its buttons. A tap selects a button, and two selected buttons make a combination. Each button shows its actions. A device profile gives the button order from the top down. Add the on-screen button: a round button that the key filter draws in an accessibility overlay, only while it has an action. The accessibility button of Android is not used, because Android shows it to every user as soon as a service asks for it. Add passive device detection. The app finds the buttons and the capabilities of the device at first start. It never asks the user to press a button. Guided setup lists every gesture and more actions, and has Advanced rows. A Power press that Android hides is offered with shell access as its need. A binding made under Advanced opens the same allow step when something is missing. Recent apps: swipe up closes the app, swipe down closes the others, one step for each sideways swipe, a hint jump on open, two tall buttons. Close works without shell access through KILL_BACKGROUND_PROCESSES. Advanced has a Hardware hacks box. All About rows are gone and the text of every screen is shorter. The accessibility disclosure stays, because Google Play requires it. Version 0.0.9-alpha, version code 9.
CHANGELOG.md | 46 +++ README.md | 19 +- app/src/main/AndroidManifest.xml | 5 + .../java/dev/equwal/assistkey/channel/Channel.kt | 14 +- .../dev/equwal/assistkey/channel/ChannelEntry.kt | 4 +- .../java/dev/equwal/assistkey/channel/Channels.kt | 10 +- .../java/dev/equwal/assistkey/device/Detect.kt | 343 +++++++++++++++++++++ .../java/dev/equwal/assistkey/device/Device.kt | 16 +- .../java/dev/equwal/assistkey/display/ExtraDim.kt | 3 +- .../equwal/assistkey/engine/KeyFilterService.kt | 18 +- .../dev/equwal/assistkey/engine/ScreenButton.kt | 120 +++++++ .../main/java/dev/equwal/assistkey/home/Apps.kt | 29 +- .../equwal/assistkey/home/HomeSettingsActivity.kt | 10 +- .../dev/equwal/assistkey/home/RecentsActivity.kt | 150 +++++---- .../dev/equwal/assistkey/menu/MenuEditActivity.kt | 5 +- .../main/java/dev/equwal/assistkey/model/HwKey.kt | 11 +- .../dev/equwal/assistkey/native/ViwoodsBridge.kt | 2 +- .../java/dev/equwal/assistkey/setup/DeviceView.kt | 104 ++++--- .../equwal/assistkey/setup/GuidedSetupActivity.kt | 168 +++++++--- .../main/java/dev/equwal/assistkey/setup/Route.kt | 53 +++- .../dev/equwal/assistkey/store/SettingsFile.kt | 4 +- .../equwal/assistkey/ui/AccessibilityDisclosure.kt | 28 +- .../equwal/assistkey/ui/ActionPickerActivity.kt | 27 +- .../dev/equwal/assistkey/ui/AdvancedActivity.kt | 84 +++-- .../java/dev/equwal/assistkey/ui/BackupActivity.kt | 18 +- .../java/dev/equwal/assistkey/ui/ChordActivity.kt | 23 +- .../java/dev/equwal/assistkey/ui/DetectActivity.kt | 85 +++++ .../dev/equwal/assistkey/ui/DisplayActivity.kt | 28 +- .../dev/equwal/assistkey/ui/HomeHubActivity.kt | 18 +- .../dev/equwal/assistkey/ui/KeyTesterActivity.kt | 28 +- .../java/dev/equwal/assistkey/ui/KeysActivity.kt | 27 +- .../dev/equwal/assistkey/ui/LicenseActivity.kt | 67 +--- .../java/dev/equwal/assistkey/ui/MainActivity.kt | 87 ++---- .../dev/equwal/assistkey/ui/NavigationActivity.kt | 72 ++--- .../dev/equwal/assistkey/ui/PermissionsActivity.kt | 37 +-- .../java/dev/equwal/assistkey/ui/PowerActivity.kt | 80 ++--- .../java/dev/equwal/assistkey/ui/ReportActivity.kt | 26 +- .../java/dev/equwal/assistkey/ui/SetupActivity.kt | 45 +-- .../java/dev/equwal/assistkey/ui/ShellActivity.kt | 46 +-- .../main/java/dev/equwal/assistkey/ui/Summary.kt | 44 +-- .../java/dev/equwal/assistkey/ui/TimingActivity.kt | 15 +- .../dev/equwal/assistkey/ui/TriggerListActivity.kt | 34 +- app/src/main/java/dev/equwal/assistkey/ui/Ui.kt | 19 -- .../dev/equwal/assistkey/ui/ViwoodsActivity.kt | 40 +-- .../java/dev/equwal/assistkey/ui/VoiceActivity.kt | 46 +-- .../java/dev/equwal/assistkey/voice/Dictation.kt | 4 +- .../equwal/assistkey/voice/DictationActivity.kt | 10 +- .../java/dev/equwal/assistkey/voice/TextInsert.kt | 2 +- app/src/main/res/values/strings.xml | 12 +- .../java/dev/equwal/assistkey/device/DetectTest.kt | 166 ++++++++++ .../dev/equwal/assistkey/home/RecentsOpenTest.kt | 30 ++ .../java/dev/equwal/assistkey/setup/RouteTest.kt | 70 ++++- .../java/dev/equwal/assistkey/ui/SummaryTest.kt | 57 +--- gradle.properties | 4 +- play/DECLARATIONS.md | 4 +- play/LISTING.md | 38 +-- 56 files changed, 1623 insertions(+), 932 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aabc6c..4cb650a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 0.0.9-alpha - 2026-09-21 + +Version code 9. + +- **New name: Rebind.** The name says what the app does, and it has the words + that buyers type in Play search. The package id stays `dev.equwal.assistkey`, + so an update keeps all settings. The settings file keeps its `AssistKey` + marker, so old exports still import. +- **Recent apps.** Swipe up closes the app. Swipe down closes all the others. + One swipe sideways moves one card in one step, with no glide. When the screen + opens, the card jumps up and sideways once to show the swipes. "Close all but + this app" and "Close all" are two tall buttons with space between them. + Closing works without shell access too: Android ends the background + processes of the app (`KILL_BACKGROUND_PROCESSES`, a normal permission). +- **Hardware hacks** have their own box under Advanced: hold Power, double + press Power, the wallet button, the full Power button, extra-dim light, and + the button settings of the device. +- **Ask when it is needed, everywhere.** A binding made under Advanced now opens + the same "allow" step as the guided setup, when something is missing. +- **The drawing is the main screen.** The device and its buttons are on the + first screen. Each button shows what it does now. Tap a button to set it up. + Tap two buttons to set up a combination. +- **Button order.** A device profile gives the order of its buttons from the + top down. Viwoods AiPaper: Power, Volume up, Volume down, AI key. +- **On-screen button.** A new button that needs no hardware: a small round + button that floats over every app. Tap it to do the action, drag it to move + it. The key filter draws it, only while it has an action. Android's own + accessibility button is not used, because Android shows that button to every + user as soon as a service asks for it. +- **More ways to press.** Step two lists tap, double tap, triple tap, hold, 4 + taps and 5 taps, and an Advanced row with every gesture of the button. +- **More actions.** Step three adds Lock screen and Screenshot, and an Advanced + row with every action. +- **Ask when it is needed.** A Power press that Android hides from apps is now + offered where the build can have shell access. The app then asks for shell + access, which opens the full Power button. After a double press of Power is + set up, the app offers the wallet way for devices that use it. +- **Device detection.** At first start the app finds which buttons the device + has, and what the device can do. It never asks the user to press a button. + Advanced > Detect this device shows the result. The device report has it too. +- **Less text.** All "More about this" rows are gone. Notes are one short + sentence. The accessibility disclosure is short. Google Play requires it, so + it stays. +- Tests: selection rules of the drawing, the on-screen button route, shell + access as a need, detection parser and round trip. + ## 0.0.8-alpha - 2026-09-21 Version code 8. diff --git a/README.md b/README.md index 9febfa9..543f3fd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# AssistKey +# Rebind + +Formerly AssistKey. The package id, the repository and the code keep the old name. 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, @@ -15,13 +17,16 @@ products, pricing and how the beta is run and ended are in ## Set up a button -The main screen starts with **Set up a button**. Tap the button in the drawing -of the device. Choose tap, double tap or hold. Choose what it does. The app -finds what Android must allow for that, and asks for only that. The app does -not ask you to press a key to find it. +The main screen is a drawing of the device. Each button shows what it does +now. Tap a button, or two buttons for a combination. Choose how you press it. +Choose what it does. The app finds what Android must allow for that, and asks +for only that. The app does not ask you to press a key to find it. + +The on-screen button is a small round button that floats over every app. It +is there only while it has an action. Drag it to move it. -The same bindings are under **Advanced > Full control**, with every key, every -gesture, the Power button and two-key combinations. +The same bindings are under **Advanced > Full control**, with every button, +every gesture, the Power button and two-button combinations. ## Two builds diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5869c67..b24282a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,10 @@ still requires the app that asks for it to hold the permission. Requested at run time, from the Voice typing screen, and never otherwise. --> + <!-- Recent apps: "close" ends the background processes of an app where + there is no shell access. A normal permission: Android grants it at install. --> + <uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- Required of any app that wants to be offered the wallet role. --> @@ -136,6 +140,7 @@ </intent-filter> </activity> <activity android:name=".ui.ReportActivity" android:exported="false" /> + <activity android:name=".ui.DetectActivity" android:exported="false" /> <!-- The three impersonation entry points. Each starts disabled: the app diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt index 545f449..06326c2 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt @@ -25,33 +25,29 @@ enum class Channel( ) { ACCESSIBILITY( "accessibility", - "Accessibility key filter", - "Captures the AI key and both volume keys. Required for multi-tap, " + - "holds, combinations, and for Back / Recents / swipe actions.", + "Button remapping", + "Sees button presses and does the action", listOf() ), ASSISTANT( "assistant", "Digital assistant", - "Captures long-press Power by holding the assistant role.", + "Catches a held Power button", listOf("dev.equwal.assistkey.channel.AssistActivity") ), CAMERA( "camera", "Camera app", - "Captures double-press Power. The firmware opens the camera on a " + - "double press, so this app has to be the default camera app.", + "Catches a double press of Power", listOf("dev.equwal.assistkey.channel.CameraShimActivity") ), WALLET( "wallet", "Wallet app", - "Captures the quick-settings wallet tile and the lock-screen wallet " + - "button, and double-press Power on firmware that sends it to the " + - "wallet. This reader sends it to the camera instead.", + "Catches the wallet tile and button", listOf( "dev.equwal.assistkey.channel.WalletActivity", "dev.equwal.assistkey.channel.WalletService" diff --git a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt index 4e845d7..6f9f824 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt @@ -43,7 +43,7 @@ object ChannelEntry { if (!licensed) { Toast.makeText( activity, - "AssistKey is locked - open the app to unlock it", + "Rebind is locked. Open the app.", Toast.LENGTH_SHORT ).show() activity.finishAndVanish() @@ -63,7 +63,7 @@ object ChannelEntry { if (!ServiceHolder.isRunning) { Toast.makeText( activity, - "Enable the AssistKey accessibility service to use this action", + "Turn on button remapping first", Toast.LENGTH_SHORT ).show() activity.finishAndVanish() diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt index 310eec4..d4e8853 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt @@ -82,12 +82,12 @@ object Channels { if (!isEnabled(c, ch)) return "Off" if (isSatisfied(c, ch)) return "Active" return when (ch) { - Channel.ACCESSIBILITY -> "Service not enabled in Settings" - Channel.ASSISTANT -> "Not the default digital assistant" + Channel.ACCESSIBILITY -> "Not on in Settings" + Channel.ASSISTANT -> "Not the assistant" Channel.WALLET -> - if (isRoleAvailable(c, ROLE_WALLET)) "Not the default wallet app" - else "No wallet role on this firmware" - Channel.CAMERA -> "Not the default camera app" + if (isRoleAvailable(c, ROLE_WALLET)) "Not the wallet app" + else "No wallet here" + Channel.CAMERA -> "Not the camera app" } } diff --git a/app/src/main/java/dev/equwal/assistkey/device/Detect.kt b/app/src/main/java/dev/equwal/assistkey/device/Detect.kt new file mode 100644 index 0000000..a468951 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/device/Detect.kt @@ -0,0 +1,343 @@ +package dev.equwal.assistkey.device + +import android.app.role.RoleManager +import android.content.Context +import android.os.Build +import android.view.InputDevice +import android.view.KeyCharacterMap +import dev.equwal.assistkey.BuildConfig +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.display.ExtraDim +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.native.PowerNative +import dev.equwal.assistkey.shell.PowerControl +import dev.equwal.assistkey.shell.Shell +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * What this device has, found by asking, not by making the user press anything. + * + * AssistKey is a key remap tool. It must never say "press the button you want + * to find". So every source here is passive: Android's own key tables, the + * input devices the framework lists, and - with shell access - the key codes + * each kernel input node declares. No press is recorded, and no sequence of + * presses is ever part of detection. + * + * The result is held in a preference file of its own. It describes one device + * and one build, so it is not a setting and never enters an export - see + * [dev.equwal.assistkey.store.SettingsFile]. + * + * The parsing and the storage format are pure functions over text, so a test + * runs them with no device. + */ +object Detect { + + /** The answer for one capability. UNKNOWN means the app may not look. */ + enum class State(val label: String) { + YES("Yes"), + NO("No"), + UNKNOWN("Unknown") + } + + /** What one detection found. [capabilities] is a capability name to its state. */ + data class Result( + val fingerprint: String, + val appVersion: String, + val name: String, + val profile: String, + val keys: List<HwKey>, + val capabilities: Map<String, State> + ) { + fun capability(name: String): State = capabilities[name] ?: State.UNKNOWN + } + + // ---- capabilities ------------------------------------------------------- + + const val KEY_FILTER = "Key filter" + const val ASSISTANT_ROLE = "Assistant role" + const val CAMERA_ROUTE = "Double press Power for camera" + const val SECURE_SETTINGS = "Write secure settings" + const val SHELL_ACCESS = "Shell access" + const val EMERGENCY_SOS = "Emergency SOS switch" + const val FRONTLIGHT = "Frontlight control" + const val NAV_OVERLAYS = "Navigation overlays" + + /** Every capability this build knows, in the order the screens show them. */ + val capabilityNames: List<String> = listOf( + KEY_FILTER, ASSISTANT_ROLE, CAMERA_ROUTE, SECURE_SETTINGS, + SHELL_ACCESS, EMERGENCY_SOS, FRONTLIGHT, NAV_OVERLAYS + ) + + // ---- scan codes --------------------------------------------------------- + + /** + * The Linux input code for each key this app can bind. `getevent` reports + * these, not Android key codes, so a detection through the shell has to + * translate. The values are from the kernel's input-event-codes.h and match + * Android's own Generic.kl. + */ + val scanCodes: Map<HwKey, Int> = mapOf( + HwKey.AI to 59, // KEY_F1: the AI key on the Viwoods reader + HwKey.F2 to 60, + HwKey.F3 to 61, + HwKey.F4 to 62, + HwKey.PAGE_UP to 104, + HwKey.PAGE_DOWN to 109, + HwKey.MUTE to 113, + HwKey.VOL_DOWN to 114, + HwKey.VOL_UP to 115, + HwKey.POWER to PowerControl.KEY_POWER, + HwKey.CAMERA to 212, + HwKey.HEADSET to 226, + HwKey.FOCUS to 528, + HwKey.ASSIST to 583 + ) + + private val byScanCode: Map<Int, HwKey> = scanCodes.entries.associate { (k, v) -> v to k } + + // ---- pure core ---------------------------------------------------------- + + /** Preference file for the result. Keep it out of the settings file. */ + const val PREFS = "assistkey_detect" + private const val K_RESULT = "result" + private const val FORMAT = 1 + + /** Marks a section in the shell probe output. */ + const val MARK = "@@" + + /** Each key once, in the order [HwKey] declares them. */ + fun ordered(keys: Collection<HwKey>): List<HwKey> = + keys.distinct().sortedBy { it.ordinal } + + /** + * The Linux key codes the input nodes declare, from `getevent -p`. A node + * prints its codes in hex after `KEY (0001):` and continues on indented + * lines until the next event class. Junk lines add nothing. + */ + fun scanCodesFromGetevent(text: String): Set<Int> { + val out = LinkedHashSet<Int>() + var inKeys = false + text.lineSequence().forEach { raw -> + val line = raw.trim() + when { + line.startsWith("KEY (0001):") -> { + inKeys = true + hexInto(line.substringAfter(':'), out) + } + line.isEmpty() || line.startsWith("add device") || + line.startsWith("name:") || line.contains("(00") -> inKeys = false + inKeys -> hexInto(line, out) + } + } + return out + } + + /** The keys of [scanCodesFromGetevent] this app has a name for. */ + fun keysFromGetevent(text: String): List<HwKey> = + ordered(scanCodesFromGetevent(text).mapNotNull { byScanCode[it] }) + + /** Adds every short hex word in [part]. Anything else is ignored. */ + private fun hexInto(part: String, out: MutableCollection<Int>) { + part.split(' ', '\t').forEach { word -> + if (word.isNotEmpty() && word.length <= 4 && word.all(::isHex)) { + word.toIntOrNull(16)?.let(out::add) + } + } + } + + private fun isHex(c: Char): Boolean = + c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' + + /** + * The text under [marker] in the shell probe output, up to the next marker. + * Empty when the marker is not there. + */ + fun section(text: String, marker: String): String { + val at = text.indexOf(MARK + marker) + if (at < 0) return "" + val from = at + MARK.length + marker.length + val end = text.indexOf(MARK, from) + return if (end < 0) text.substring(from) else text.substring(from, end) + } + + fun encode(r: Result): String = JSONObject() + .put("format", FORMAT) + .put("fingerprint", r.fingerprint) + .put("app", r.appVersion) + .put("name", r.name) + .put("profile", r.profile) + .put("keys", JSONArray(r.keys.map { it.token })) + .put("capabilities", JSONObject().also { o -> + r.capabilities.forEach { (name, state) -> o.put(name, state.name) } + }) + .toString() + + /** + * The opposite of [encode]. Null for text this build cannot read. A key or + * a capability this build does not know is dropped, so an older build can + * still read a newer file. + */ + fun decode(text: String): Result? { + val root = runCatching { JSONObject(text) }.getOrNull() ?: return null + if (root.optInt("format", -1) != FORMAT) return null + val keys = root.optJSONArray("keys") ?: JSONArray() + val caps = root.optJSONObject("capabilities") ?: JSONObject() + val states = LinkedHashMap<String, State>() + capabilityNames.forEach { name -> + if (!caps.has(name)) return@forEach + val value = runCatching { State.valueOf(caps.optString(name)) }.getOrNull() + if (value != null) states[name] = value + } + return Result( + fingerprint = root.optString("fingerprint"), + appVersion = root.optString("app"), + name = root.optString("name"), + profile = root.optString("profile"), + keys = ordered((0 until keys.length()).mapNotNull { HwKey.fromToken(keys.optString(it)) }), + capabilities = states + ) + } + + // ---- storage ------------------------------------------------------------ + + private var cachedText: String? = null + private var cached: Result? = null + + private fun prefs(c: Context) = + c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** The last detection, or null when none has run on this device yet. */ + fun stored(c: Context): Result? { + val text = prefs(c).getString(K_RESULT, null) ?: return null + if (text != cachedText) { + cached = decode(text) + cachedText = text + } + return cached + } + + fun save(c: Context, r: Result) { + val text = encode(r) + cachedText = text + cached = r + prefs(c).edit().putString(K_RESULT, text).apply() + } + + /** + * Detects once at first start, and again after a firmware or app change. + * A stored result that still matches costs one preference read. + * + * A result that was found without shell access is also stale as soon as + * there is shell access, because the shell sees input nodes the framework + * does not report. The other way round it stands: detection only adds. + */ + fun refreshIfStale(c: Context) { + val have = stored(c) + if (have != null && + have.fingerprint == Build.FINGERPRINT && + have.appVersion == BuildConfig.VERSION_NAME && + !(have.capability(SHELL_ACCESS) != State.YES && Shell.ready) + ) { + return + } + val app = c.applicationContext + run(app) { save(app, it) } + } + + // ---- the probe ---------------------------------------------------------- + + /** + * Asks the device what it has. [done] comes back on the main thread. The + * framework part is a handful of table lookups; only the shell part, which + * runs off the main thread, takes any time. + */ + fun run(c: Context, done: (Result) -> Unit) { + val app = c.applicationContext + val caps = LinkedHashMap<String, State>() + caps[KEY_FILTER] = yesNo(Channels.isAccessibilityOn(app)) + caps[ASSISTANT_ROLE] = yesNo(Channels.isRoleAvailable(app, RoleManager.ROLE_ASSISTANT)) + caps[CAMERA_ROUTE] = maybe(PowerNative.cameraDoubleTapEnabled(app)) + caps[SECURE_SETTINGS] = yesNo(PowerNative.canWriteSecure(app)) + caps[SHELL_ACCESS] = yesNo(Shell.ready) + caps[EMERGENCY_SOS] = yesNo(PowerNative.canWriteSecure(app) || Shell.ready) + + val framework = frameworkKeys() + if (!Shell.ready) { + // Without the shell the light node is usually unreadable, and the + // overlays cannot be listed at all. Say so rather than guess. + caps[FRONTLIGHT] = if (anyNode()) State.YES else State.UNKNOWN + caps[NAV_OVERLAYS] = State.UNKNOWN + return done(result(framework, caps)) + } + Shell.run(probeScript()) { r -> + val keys = if (r.ok) keysFromGetevent(r.output.substringBefore(MARK)) else emptyList() + caps[FRONTLIGHT] = when { + !r.ok -> State.UNKNOWN + section(r.output, "light").contains("present") -> State.YES + else -> State.NO + } + val navbars = section(r.output, "nav").trim().toIntOrNull() + caps[NAV_OVERLAYS] = when { + !r.ok || navbars == null -> State.UNKNOWN + navbars > 0 -> State.YES + else -> State.NO + } + done(result(ordered(framework + keys), caps)) + } + } + + private fun result(keys: List<HwKey>, caps: Map<String, State>): Result = Result( + fingerprint = Build.FINGERPRINT, + appVersion = BuildConfig.VERSION_NAME, + name = Device.name, + profile = Device.profile.name, + keys = keys, + capabilities = caps + ) + + private fun yesNo(on: Boolean): State = if (on) State.YES else State.NO + + private fun maybe(on: Boolean?): State = if (on == null) State.UNKNOWN else yesNo(on) + + /** + * What the framework says this device has. [KeyCharacterMap.deviceHasKey] + * asks every input device; the second pass asks each device by itself and + * leaves out full keyboards, because a typing keyboard is not a button on + * this device. + */ + private fun frameworkKeys(): List<HwKey> { + val found = LinkedHashSet<HwKey>() + HwKey.entries.forEach { key -> + if (runCatching { KeyCharacterMap.deviceHasKey(key.code) }.getOrDefault(false)) { + found += key + } + } + val codes = HwKey.entries.map { it.code }.toIntArray() + runCatching { InputDevice.getDeviceIds() }.getOrDefault(IntArray(0)).forEach { id -> + val device = runCatching { InputDevice.getDevice(id) }.getOrNull() ?: return@forEach + if (device.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) return@forEach + val has = runCatching { device.hasKeys(*codes) }.getOrNull() ?: return@forEach + HwKey.entries.forEachIndexed { i, key -> if (has.getOrNull(i) == true) found += key } + } + return ordered(found) + } + + private fun anyNode(): Boolean = + ExtraDim.nodes.any { runCatching { File(it).exists() }.getOrDefault(false) } + + /** + * One shell command for the three things only the shell can see: the key + * codes of every input node, the navigation overlays, and the light node. + */ + private fun probeScript(): String { + val nodes = ExtraDim.nodes.joinToString(" ") { "'" + it + "'" } + return listOf( + "getevent -p 2>/dev/null", + "echo '" + MARK + "nav'; cmd overlay list 2>/dev/null | grep -ci navbar", + "echo '" + MARK + "light'; for n in " + nodes + + "; do [ -e \$n ] && echo present; done" + ).joinToString("; ") + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/device/Device.kt b/app/src/main/java/dev/equwal/assistkey/device/Device.kt index 6091343..d0649a6 100644 --- a/app/src/main/java/dev/equwal/assistkey/device/Device.kt +++ b/app/src/main/java/dev/equwal/assistkey/device/Device.kt @@ -69,18 +69,24 @@ object 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) + // Top down, as the buttons sit on the edge of the device. + get() = if (isViwoods) listOf(HwKey.VOL_UP, HwKey.VOL_DOWN, HwKey.AI) 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. + * The remappable keys to show: what the profile knows about, what detection + * found, plus any other supported key this device has actually produced. + * + * Detection only adds. A profile key stays on the list even if no source + * reports it, because a firmware hook can hide a key that is really there. */ 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() + val detected = Detect.stored(c)?.keys.orEmpty().filter { it.interceptable } + // The profile knows where its buttons sit. The other keys follow in a fixed order. + val others = (detected + HwKey.interceptable.filter { it.token in seen }).sortedBy { it.ordinal } + return (builtIn + others).distinct() } private const val K_UNKNOWN = "unknown_keys" diff --git a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt index 9bb2eea..d2d5e7a 100644 --- a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt +++ b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt @@ -24,7 +24,8 @@ object ExtraDim { private const val PREFS = "assistkey_display" private const val K_LEVEL = "extra_dim" - private val nodes = listOf( + /** The light nodes to try, in order. Detection looks for the same ones. */ + val nodes = listOf( "/sys/class/leds/lcd-backlight/brightness", "/sys/class/backlight/panel0-backlight/brightness" ) 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 c77a85b..03b28ed 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -45,7 +45,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { PlayBilling.refresh(this) { syncPower() } Shell.onChange(shellChanged) - Store.onBindingsChanged = { syncPower() } + Store.onBindingsChanged = { syncPower(); handler.post { syncScreenButton() } } registerReceiver( screenState, android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_ON).apply { @@ -54,6 +54,21 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { ) Shell.connect(this) syncPower() + syncScreenButton() + } + + // ---- the on-screen button -------------------------------------------------------------- + + private val screenTap = Trigger(setOf(HwKey.SCREEN), dev.equwal.assistkey.model.GestureType.TAP, 1) + private val screenButton by lazy { ScreenButton(this) { fire(screenTap) } } + + /** The button floats only while it has a binding, and only while the app may act. */ + fun syncScreenButton() { + // "Nothing" on a physical key stops the key. On this button it means: no button. + val action = bindings()[screenTap]?.kind + val wanted = action != null && action != ActionKind.NONE && + Channels.isEnabled(this, Channel.ACCESSIBILITY) && License.active(this) + if (wanted) screenButton.show() else screenButton.hide() } // ---- the Power button, when there is shell access ----------------------------- @@ -123,6 +138,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { override fun onUnbind(intent: android.content.Intent?): Boolean { cancelAll() stopPower() + screenButton.hide() return super.onUnbind(intent) } diff --git a/app/src/main/java/dev/equwal/assistkey/engine/ScreenButton.kt b/app/src/main/java/dev/equwal/assistkey/engine/ScreenButton.kt new file mode 100644 index 0000000..328c957 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/engine/ScreenButton.kt @@ -0,0 +1,120 @@ +package dev.equwal.assistkey.engine + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PixelFormat +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.WindowManager +import kotlin.math.abs + +/** + * A small round button that floats above every app. + * + * The key filter draws it in an accessibility overlay window, which needs no + * permission of its own. It is on the screen only while a binding for it + * exists, so a user who does not want it never sees it. A tap runs the + * binding. A drag moves the button, and the place is kept. + * + * Android has its own accessibility button. It is not used: Android shows that + * button to every user as soon as a service asks for it, bound or not. + * + * Drawn for e-ink: a white disc with a black ring, no shadow, no animation. + */ +class ScreenButton(private val service: AccessibilityService, private val onTap: () -> Unit) { + + private val wm = service.getSystemService(WindowManager::class.java) + private val prefs = service.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val d = service.resources.displayMetrics.density + private val size = (48 * d).toInt() + private var view: View? = null + + private val params = WindowManager.LayoutParams( + size, size, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ).apply { gravity = Gravity.TOP or Gravity.START } + + val showing: Boolean get() = view != null + + fun show() { + if (view != null) return + val screen = service.resources.displayMetrics + params.x = prefs.getInt(K_X, screen.widthPixels - size - (12 * d).toInt()) + params.y = prefs.getInt(K_Y, screen.heightPixels * 2 / 3) + val v = Disc(service) + runCatching { wm.addView(v, params) }.onSuccess { view = v } + } + + fun hide() { + view?.let { runCatching { wm.removeView(it) } } + view = null + } + + private inner class Disc(c: Context) : View(c) { + private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE; strokeWidth = 3 * d; color = Color.BLACK + } + private val paper = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.WHITE } + private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.BLACK } + private val slop = ViewConfiguration.get(c).scaledTouchSlop + private var downX = 0f + private var downY = 0f + private var startX = 0 + private var startY = 0 + private var dragged = false + + override fun onDraw(canvas: Canvas) { + val r = width / 2f + canvas.drawCircle(r, r, r - 2 * d, paper) + canvas.drawCircle(r, r, r - 3 * d, ring) + canvas.drawCircle(r, r, r * 0.30f, dot) + } + + override fun onTouchEvent(e: MotionEvent): Boolean { + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = e.rawX; downY = e.rawY + startX = params.x; startY = params.y + dragged = false + } + MotionEvent.ACTION_MOVE -> { + val dx = e.rawX - downX + val dy = e.rawY - downY + if (!dragged && (abs(dx) > slop || abs(dy) > slop)) dragged = true + if (dragged) { + params.x = startX + dx.toInt() + params.y = startY + dy.toInt() + runCatching { wm.updateViewLayout(this, params) } + } + } + MotionEvent.ACTION_UP -> { + if (dragged) { + prefs.edit().putInt(K_X, params.x).putInt(K_Y, params.y).apply() + } else { + performClick() + } + } + } + return true + } + + override fun performClick(): Boolean { + super.performClick() + onTap() + return true + } + } + + private companion object { + const val PREFS = "assistkey_screen_button" + const val K_X = "x" + const val K_Y = "y" + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt index 20e7420..3ccaa35 100644 --- a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt +++ b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt @@ -93,7 +93,34 @@ object Apps { 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) + return rank(c, open(last, closedAt(c)), limit) + } + + /** + * The packages to show, most recent first. An app the user closed stays + * off the list until the user opens that app again. + */ + fun open(lastUsed: Map<String, Long>, closedAt: Map<String, Long>): List<String> = + lastUsed.entries + .filter { (pkg, at) -> at > (closedAt[pkg] ?: Long.MIN_VALUE) } + .sortedByDescending { it.value } + .map { it.key } + + private const val RECENTS_PREFS = "assistkey_recents" + + private fun closedAt(c: Context): Map<String, Long> = + c.getSharedPreferences(RECENTS_PREFS, Context.MODE_PRIVATE).all + .mapNotNull { (pkg, at) -> (at as? Long)?.let { pkg to it } }.toMap() + + /** + * Closes [pkg] without shell access: Android ends the background processes + * of the app, and the app leaves the list of this screen. + */ + fun closeWithoutShell(c: Context, pkg: String) { + if (pkg == c.packageName) return + runCatching { c.getSystemService(android.app.ActivityManager::class.java)?.killBackgroundProcesses(pkg) } + c.getSharedPreferences(RECENTS_PREFS, Context.MODE_PRIVATE).edit() + .putLong(pkg, System.currentTimeMillis()).apply() } /** One entry of the recent-apps list. [taskId] is known only with shell access. */ diff --git a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt index 34c0532..fbecb26 100644 --- a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt @@ -41,9 +41,8 @@ class HomeSettingsActivity : Activity() { private fun build() { val col = Ui.page(this, "Home screen") - col.note("A clock, the few apps you choose, and a line to type in.") - col.check("Offer AssistKey as a home screen", null, enabled()) { on -> + col.check("Offer Rebind as a home screen", null, enabled()) { on -> packageManager.setComponentEnabledSetting( home, if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED @@ -56,7 +55,7 @@ class HomeSettingsActivity : Activity() { col.row( "Home screen", - if (isDefault()) "This is your home screen" else "Not your home screen yet", + null, enabled = false, state = if (isDefault()) "In use" else "Ready" ) @@ -103,9 +102,6 @@ class HomeSettingsActivity : Activity() { 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." - ) + col.note("Press and hold 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 index 4754383..1be789a 100644 --- a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt @@ -48,11 +48,19 @@ class RecentsActivity : Activity() { private var scroller: HorizontalScrollView? = null private var strip: LinearLayout? = null private var heading: TextView? = null - private var closeOthers: android.widget.Button? = null + private var closeOthers: TextView? = null private var names: List<String> = emptyList() + private var shown: List<Apps.Recent> = emptyList() private var cardStep = 0 private var index = 0 + /** Each time the screen opens, it starts at the newest app and shows the hint again. */ + override fun onStart() { + super.onStart() + index = 0 + hinted = false + } + override fun onResume() { super.onResume() load() @@ -80,6 +88,7 @@ class RecentsActivity : Activity() { val gap = dp(16) cardStep = cardW + gap names = recents.map { it.app.label } + shown = recents index = index.coerceIn(0, maxOf(0, recents.size - 1)) val root = LinearLayout(this).apply { @@ -116,32 +125,33 @@ class RecentsActivity : Activity() { row = cards val scroll = object : HorizontalScrollView(this) { - private var startX = 0 + private val slop = android.view.ViewConfiguration.get(context).scaledTouchSlop + private var downX = 0f + private var downY = 0f + /** A sideways drag belongs to the row. A tap or an upward swipe belongs to the card. */ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - if (ev.actionMasked == MotionEvent.ACTION_DOWN) startX = scrollX - return super.onInterceptTouchEvent(ev) + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { downX = ev.x; downY = ev.y } + MotionEvent.ACTION_MOVE -> { + val dx = abs(ev.x - downX) + if (dx > slop && dx > abs(ev.y - downY)) return true + } + } + return false } /** - * One swipe, one card. The row follows the finger, and on release it - * goes to the next card in the direction of the swipe, however far - * or fast the swipe was. A very short drag goes back where it was. + * One swipe, one card, in one step. The row does not follow the + * finger: e-ink shows every frame of a moving row as a smear. */ override fun onTouchEvent(ev: MotionEvent): Boolean { - if (ev.actionMasked == MotionEvent.ACTION_DOWN) startX = scrollX - val handled = super.onTouchEvent(ev) - if (ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL) { - val moved = scrollX - startX - val from = Math.round(startX / cardStep.toFloat()) - val turn = if (abs(moved) < cardStep / 8) 0 else if (moved > 0) 1 else -1 - show(from + turn) + if (ev.actionMasked == MotionEvent.ACTION_UP) { + val dx = ev.x - downX + if (abs(dx) > slop) show(index + if (dx < 0) 1 else -1) } - return handled + return true } - - /** No fling and no glide: e-ink shows every frame of one as a smear. */ - override fun fling(velocityX: Int) = Unit }.apply { isHorizontalScrollBarEnabled = false overScrollMode = View.OVER_SCROLL_NEVER @@ -178,42 +188,73 @@ class RecentsActivity : Activity() { LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) ) - root.addView(TextView(this).apply { - text = if (closable) "Tap a card to open it. Swipe a card up to close it." - else "Tap a card to open it." - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(20), dp(14), dp(20), dp(8)) - }) closeOthers = null - if (closable && recents.isNotEmpty()) { - val buttons = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_HORIZONTAL + if (recents.isNotEmpty()) { + // Two tall buttons, one above the other with space between, so that + // a finger does not hit the wrong one. + fun bigButton(black: Boolean, onClick: () -> Unit) = TextView(this).apply { + gravity = Gravity.CENTER + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + typeface = Typeface.DEFAULT_BOLD + setSingleLine() + setTextColor(if (black) Color.WHITE else Color.BLACK) + background = GradientDrawable().apply { + setColor(if (black) Color.BLACK else Color.WHITE) + setStroke(dp(2), Color.BLACK) + cornerRadius = dp(8).toFloat() + } + setOnClickListener { onClick() } } - buttons.addView(android.widget.Button(this).apply { - text = "Close all" - setOnClickListener { close(recents.mapNotNull { it.taskId }) } - }) + fun place(v: View, top: Int) = root.addView( + v, + LinearLayout.LayoutParams(MATCH_PARENT, dp(60)).apply { setMargins(dp(20), top, dp(20), 0) } + ) if (recents.size > 1) { - closeOthers = android.widget.Button(this).apply { - // The name is filled in by show(i): it is the card in the middle. - setOnClickListener { - close(recents.filterIndexed { i, _ -> i != index }.mapNotNull { it.taskId }) - } - } - buttons.addView(closeOthers) + // The name is filled in by show(i): it is the card in the middle. + closeOthers = bigButton(black = true) { close(recents.filterIndexed { i, _ -> i != index }) } + place(closeOthers!!, dp(8)) } - root.addView(buttons, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + val all = bigButton(black = false) { close(recents) }.apply { text = "Close all" } + place(all, dp(20)) } setContentView(root) - scroll.post { show(index) } + scroll.post { show(index); hint() } } - private fun close(taskIds: List<Int>) { + /** + * Closes apps. With shell access the task leaves the list of the system and + * its background processes end. Without it, Android ends the background + * processes and the app leaves the list of this screen. + */ + private fun close(apps: List<Apps.Recent>) { index = 0 - Shell.runAll(taskIds.map { "am stack remove $it" }) { load() } + val mine = apps.filter { it.app.pkg != packageName } + val (withTask, without) = mine.partition { it.taskId != null && Shell.ready } + without.forEach { Apps.closeWithoutShell(this, it.app.pkg) } + if (withTask.isEmpty()) return load() + Shell.runAll(withTask.flatMap { listOf("am stack remove " + it.taskId, "am kill " + it.app.pkg) }) { load() } + } + + private var hinted = false + + /** + * Shows once what a swipe does: the card in the middle jumps up and comes + * back, then the row jumps sideways and comes back. Four still frames, no + * glide, so that e-ink draws them clean. + */ + private fun hint() { + if (hinted) return + hinted = true + val s = scroller ?: return + val card = row?.getChildAt(index) ?: return + val many = (row?.childCount ?: 0) > 1 + s.postDelayed({ card.translationY = -dp(28).toFloat() }, 500) + s.postDelayed({ card.translationY = 0f }, 900) + if (many) { + val side = if (index == 0) dp(40) else -dp(40) + s.postDelayed({ s.scrollTo(index * cardStep + side, 0) }, 1300) + s.postDelayed({ s.scrollTo(index * cardStep, 0) }, 1700) + } } /** Puts card [i] in the middle, in one step, and says which one it is. */ @@ -272,10 +313,10 @@ class RecentsActivity : Activity() { setPadding(p, p, p, p) } box.addView(body, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) - if (closable && r.taskId != null) { + if (r.app.pkg != packageName) { box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1))) box.addView(TextView(this).apply { - text = "\u2191 Swipe up to close" + text = "\u2191 close this \u2193 close the others" setTextColor(Color.BLACK) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) gravity = Gravity.CENTER_HORIZONTAL @@ -303,8 +344,12 @@ class RecentsActivity : Activity() { override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean { val up = e1 != null && e1.y - e2.y > box.height / 5 && abs(vy) > abs(vx) - if (!up || !closable || r.taskId == null) return false - Shell.run("am stack remove " + r.taskId) { load() } + val down = e1 != null && e2.y - e1.y > box.height / 5 && abs(vy) > abs(vx) + when { + up -> close(listOf(r)) + down -> close(shown.filter { it !== r }) + else -> return false + } return true } }) @@ -320,12 +365,7 @@ class RecentsActivity : Activity() { private fun askForAccess() { val col = Ui.page(this) col.title("Recent apps") - col.note( - "Android does not tell apps what has been used recently. Give AssistKey " + - "usage access and it can put your apps in order of last use. On some " + - "devices the setting is named App usage data." + - if (Shell.SUPPORTED) " Shell access gives the exact list the system keeps." else "" - ) + col.note("Usage access puts your apps in order of last use.") col.button("Give usage access") { runCatching { startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) } } diff --git a/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt index 370ee2b..5200e6f 100644 --- a/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt @@ -12,7 +12,6 @@ import dev.equwal.assistkey.ui.ActionPickerActivity 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.primaryButton import dev.equwal.assistkey.ui.Ui.row import org.json.JSONObject @@ -39,9 +38,8 @@ class MenuEditActivity : Activity() { private fun build() { val col = Ui.page(this, "Menu for " + trigger.label()) - col.note("One gesture opens a menu, so you get more actions than you have keys.") - col.header(if (items.isEmpty()) "No actions yet" else "Actions, in menu order") + col.header(if (items.isEmpty()) "No actions yet" else "Actions, in order") items.forEachIndexed { i, spec -> col.row(spec.describe(), "Tap to move or remove") { val choices = listOf("Move up", "Move down", "Remove") @@ -59,7 +57,6 @@ class MenuEditActivity : Activity() { startActivityForResult(ActionPickerActivity.pickIntent(this, trigger), PICK) } - col.header("Finish") col.primaryButton(if (items.isEmpty()) "Remove the menu" else "Save the menu") { Store.bind(this, trigger, if (items.isEmpty()) ActionSpec.PASS else Menu.spec(items)) setResult(RESULT_OK) 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 320fc93..f0d2e18 100644 --- a/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt +++ b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt @@ -34,13 +34,20 @@ enum class HwKey( 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); + POWER(KeyEvent.KEYCODE_POWER, "Power", false), + + /** + * The accessibility button that Android draws on the screen. It is not a + * physical key and has no key code. The system tells the key filter about + * a tap on it, and that is the only gesture it has. + */ + SCREEN(-1, "On-screen button", 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 fromCode(code: Int): HwKey? = entries.firstOrNull { it.code == code && it.code >= 0 } fun fromToken(t: String): HwKey? = entries.firstOrNull { it.token == t } /** The keys the accessibility engine is allowed to grab. */ 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 27e5c1d..ca0e749 100644 --- a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt +++ b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt @@ -104,7 +104,7 @@ object ViwoodsBridge { /** Human description of whatever the firmware currently has bound. */ fun describe(c: Context, key: HwKey): String { val v = read(c, key) ?: return "Not set" - if (key == HwKey.AI) return if (aiHookedToUs(c)) "AssistKey" else shortName(c, v) + if (key == HwKey.AI) return if (aiHookedToUs(c)) "Rebind" else shortName(c, v) val label = volumeTokens[v] ?: return shortName(c, v) if (v != TOKEN_APP) return label diff --git a/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt b/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt index 75bab24..5d325b2 100644 --- a/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt +++ b/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt @@ -6,31 +6,37 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface -import android.view.GestureDetector +import android.text.TextUtils +import android.text.TextPaint import android.view.MotionEvent import android.view.View import dev.equwal.assistkey.model.HwKey -import kotlin.math.abs /** * A drawing of the device with its buttons along one edge. * - * It is the way to choose a button: tap a button in the drawing, or swipe up and - * down through them. The drawing is a diagram, not a photograph: it shows which - * buttons there are, in a fixed order, and does not claim where they sit on a - * given device. The chosen button is filled black and its name is bold. + * It is the way to choose buttons: tap a button in the drawing to select it, + * tap it again to let it go. Two buttons can be selected together, which makes + * a combination. [Route.toggle] holds the rules. The buttons are drawn from the + * top down in the order of [keys], which the device profile gives. The + * on-screen button is drawn on the screen of the device, because that is + * where it is. A selected button is filled black and its name is bold. * * Drawn for e-ink: black lines on white, no shading, no animation. */ -class DeviceView(context: Context) : View(context) { +class DeviceView(context: Context, private val heightDp: Int = 300) : View(context) { var keys: List<HwKey> = emptyList() set(value) { field = value; invalidate() } - var selected: HwKey? = null + var selected: List<HwKey> = emptyList() set(value) { field = value; invalidate() } - var onSelect: ((HwKey) -> Unit)? = null + /** A short second line under the name of a button, for example what it does now. */ + var captions: Map<HwKey, String> = emptyMap() + set(value) { field = value; invalidate() } + + var onSelect: ((List<HwKey>) -> Unit)? = null private val d = resources.displayMetrics.density private val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -40,48 +46,32 @@ class DeviceView(context: Context) : View(context) { } private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.BLACK } private val paper = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.WHITE } - private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; textSize = 17f * d } + private val label = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; textSize = 17f * d } + private val caption = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF444444.toInt(); textSize = 13f * d } private val rows = ArrayList<Pair<HwKey, RectF>>() - private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean = true - - override fun onSingleTapUp(e: MotionEvent): Boolean { - rows.firstOrNull { it.second.contains(e.x, e.y) }?.let { choose(it.first) } - return true - } - - /** A swipe up or down goes to the next or the previous button. */ - override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean { - if (keys.isEmpty() || abs(vy) < abs(vx)) return false - val i = keys.indexOf(selected) - val next = if (vy < 0) i + 1 else i - 1 - choose(keys[next.coerceIn(0, keys.size - 1)]) - return true - } - }) - - private fun choose(key: HwKey) { - selected = key - onSelect?.invoke(key) - } - override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_UP) performClick() - return gestures.onTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_UP) { + rows.firstOrNull { it.second.contains(event.x, event.y) }?.let { (key, _) -> + selected = Route.toggle(selected, key) + onSelect?.invoke(selected) + } + performClick() + } + return true } override fun performClick(): Boolean = super.performClick() override fun onMeasure(widthSpec: Int, heightSpec: Int) { val w = MeasureSpec.getSize(widthSpec) - setMeasuredDimension(w, (300 * d).toInt()) + setMeasuredDimension(w, (heightDp * d).toInt()) } override fun onDraw(canvas: Canvas) { val w = width.toFloat() val h = height.toFloat() - val body = RectF(w * 0.10f, 6 * d, w * 0.46f, h - 6 * d) + val body = RectF(w * 0.06f, 6 * d, w * 0.40f, h - 6 * d) val corner = 18 * d canvas.drawRoundRect(body, corner, corner, paper) canvas.drawRoundRect(body, corner, corner, line) @@ -91,18 +81,42 @@ class DeviceView(context: Context) : View(context) { rows.clear() if (keys.isEmpty()) return val slot = (body.height() - 2 * corner) / keys.size + // A device with many buttons gives each name less room. Shrink the + // name to the slot so the list still reads. + val twoLines = captions.isNotEmpty() + label.textSize = minOf(17f * d, slot * (if (twoLines) 0.42f else 0.7f)) + caption.textSize = minOf(13f * d, slot * 0.32f) + val textLeft = w * 0.50f + val textWidth = w - textLeft keys.forEachIndexed { i, key -> val cy = body.top + corner + slot * (i + 0.5f) - val half = minOf(slot * 0.32f, 20 * d) - val nub = RectF(body.right - 1 * d, cy - half, body.right + 12 * d, cy + half) - val chosen = key == selected - canvas.drawRoundRect(nub, 4 * d, 4 * d, if (chosen) fill else paper) - canvas.drawRoundRect(nub, 4 * d, 4 * d, line) + val chosen = key in selected + val leaderFrom: Float + if (key == HwKey.SCREEN) { + // A round button on the screen of the device. + val r = minOf(slot * 0.30f, 13 * d) + val cx = body.right - inset - r - 6 * d + canvas.drawCircle(cx, cy, r, if (chosen) fill else paper) + canvas.drawCircle(cx, cy, r, line) + leaderFrom = cx + r + } else { + val half = minOf(slot * 0.32f, 20 * d) + val nub = RectF(body.right - 1 * d, cy - half, body.right + 12 * d, cy + half) + canvas.drawRoundRect(nub, 4 * d, 4 * d, if (chosen) fill else paper) + canvas.drawRoundRect(nub, 4 * d, 4 * d, line) + leaderFrom = nub.right + } // A thin leader from the button to its name. - canvas.drawLine(nub.right, cy, w * 0.56f, cy, line) + canvas.drawLine(leaderFrom, cy, textLeft - 8 * d, cy, line) label.typeface = if (chosen) Typeface.DEFAULT_BOLD else Typeface.DEFAULT - canvas.drawText(key.label, w * 0.58f, cy + label.textSize * 0.35f, label) - rows.add(key to RectF(body.right - 8 * d, cy - slot / 2, w, cy + slot / 2)) + val note = captions[key] + val base = if (note == null) cy + label.textSize * 0.35f else cy - 2 * d + canvas.drawText(key.label, textLeft, base, label) + if (note != null) { + val cut = TextUtils.ellipsize(note, caption, textWidth, TextUtils.TruncateAt.END).toString() + canvas.drawText(cut, textLeft, base + caption.textSize * 1.35f, caption) + } + rows.add(key to RectF(body.right - inset - 40 * d, cy - slot / 2, w, cy + slot / 2)) } } } diff --git a/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt index 8ca6789..25383e2 100644 --- a/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt @@ -25,6 +25,9 @@ import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.AccessibilityDisclosure import dev.equwal.assistkey.ui.ActionPickerActivity +import dev.equwal.assistkey.ui.PowerActivity +import dev.equwal.assistkey.ui.ShellActivity +import dev.equwal.assistkey.ui.TriggerListActivity import dev.equwal.assistkey.ui.Ui import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header @@ -36,8 +39,9 @@ import dev.equwal.assistkey.voice.Dictation /** * Set up one button by saying what you want, in four short steps. * - * 1. Which button: tap it in the drawing. - * 2. How you press it: tap, double tap, hold. + * 1. Which button: tap it in the drawing. Two buttons make a combination. + * The main screen has the same drawing, and then this step is skipped. + * 2. How you press it: tap, double tap, hold, and the longer tap counts. * 3. What it does. * 4. Allow what that needs - and only that. * @@ -51,25 +55,46 @@ class GuidedSetupActivity : Activity() { private enum class Step { BUTTON, PRESS, ACTION, ALLOW, DONE } private var step = Step.BUTTON - private var key: HwKey? = null + private var keys: List<HwKey> = emptyList() private var trigger: Trigger? = null - private val env: Route.Env - get() = Route.Env(powerIsDirect = Shell.ready && PowerControl.wanted(this)) + /** True when the main screen chose the buttons. Back from step 2 then leaves this screen. */ + private var fromHub = false + + /** True when another screen made the binding. This screen then only asks, and leaves. */ + private var askOnly = false + + private fun tokens(text: String?): List<HwKey> = + text.orEmpty().split('+').mapNotNull(HwKey::fromToken) + + private val env: Route.Env get() = env(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedInstanceState?.let { s -> step = Step.valueOf(s.getString("step", Step.BUTTON.name)) - key = s.getString("key")?.let(HwKey::fromToken) + keys = tokens(s.getString("keys")) trigger = s.getString("trigger")?.let(Trigger::parse) + fromHub = s.getBoolean("fromHub") + } ?: intent.getStringExtra(EXTRA_ASK)?.let(Trigger::parse)?.let { + // Another screen made this binding. Ask for what it needs, and nothing else. + trigger = it + keys = it.keys.toList() + askOnly = true + step = Step.ALLOW + } ?: tokens(intent.getStringExtra(EXTRA_KEYS)).takeIf { it.isNotEmpty() }?.let { + // The main screen chose the buttons already. + keys = it + fromHub = true + step = Step.PRESS } } override fun onSaveInstanceState(out: Bundle) { super.onSaveInstanceState(out) out.putString("step", step.name) - key?.let { out.putString("key", it.token) } + out.putString("keys", keys.joinToString("+") { it.token }) + out.putBoolean("fromHub", fromHub) trigger?.let { out.putString("trigger", it.id) } } @@ -85,6 +110,8 @@ class GuidedSetupActivity : Activity() { override fun onBackPressed() { when (step) { Step.BUTTON, Step.DONE -> finish() + Step.ALLOW -> if (askOnly) finish() else go(Step.ACTION) + Step.PRESS -> if (fromHub) finish() else go(Step.BUTTON) else -> { step = Step.entries[step.ordinal - 1]; build() } } } @@ -97,7 +124,7 @@ class GuidedSetupActivity : Activity() { private fun build() { val heading = when (step) { Step.BUTTON -> "Which button?" - Step.PRESS -> key?.label ?: "Which button?" + Step.PRESS -> keys.joinToString(" + ") { it.label }.ifEmpty { "Which button?" } Step.ACTION -> trigger?.label() ?: "What should it do?" Step.ALLOW -> "Almost there" Step.DONE -> "Done" @@ -115,46 +142,68 @@ class GuidedSetupActivity : Activity() { // ---- 1. which button ------------------------------------------------------------------ private fun whichButton(col: LinearLayout) { - col.note("Tap the button in the drawing.") + col.note("Tap two for a combination.") val view = DeviceView(this).apply { - keys = listOf(HwKey.POWER) + Device.keys(this@GuidedSetupActivity) - selected = key - onSelect = { key = it; build() } + keys = drawn(this@GuidedSetupActivity) + selected = this@GuidedSetupActivity.keys + onSelect = { this@GuidedSetupActivity.keys = it; build() } } col.addView(view) // A device can have buttons the drawing does not know. The user names the button. val others = HwKey.interceptable.filterNot { it in view.keys } if (others.isNotEmpty()) { - col.row("My button is not in the drawing", key?.takeIf { it in others }?.label) { - Ui.pick(this, "Which button?", others.map { it.label }) { i -> key = others[i]; build() } + col.row("My button is not in the drawing", keys.filter { it in others }.joinToString { it.label }.ifEmpty { null }) { + Ui.pick(this, "Which button?", others.map { it.label }) { i -> + keys = Route.toggle(keys, others[i]) + build() + } } } - key?.let { k -> col.primaryButton("Next: " + k.label) { go(Step.PRESS) } } + if (keys.isNotEmpty()) { + col.primaryButton("Next: " + keys.joinToString(" + ") { it.label }) { go(Step.PRESS) } + } } // ---- 2. how it is pressed --------------------------------------------------------------- private fun press(col: LinearLayout) { - val k = key ?: return go(Step.BUTTON) + if (keys.isEmpty()) return go(Step.BUTTON) + val set = keys.toSet() col.header("How do you press it?") val b = Store.bindings(this) - val offered = Route.gestures(setOf(k), env) + val offered = Route.gestures(set, env) offered.forEach { t -> - col.row(gestureName(t), b.raw(t).describe().takeIf { b.isBound(t) }?.let { "Now: $it" }) { + val state = b.raw(t).describe().takeIf { b.isBound(t) }?.let { "Now: $it" } + ?: "Needs shell access".takeIf { Need.SHELL_POWER in Route.plan(t, false, env).needs } + col.row(gestureName(t), state) { trigger = t go(Step.ACTION) } } - if (offered.size < 3) { - col.note("Android does not show a single tap of Power to apps. Hold and double press work.") + when { + HwKey.SCREEN in set -> col.note("A small button floats over every app. Drag to move it.") + HwKey.POWER in set && set.size > 1 -> + col.note("Hold Power, then press the other button.") + HwKey.POWER in set && !env.powerIsDirect && !env.shellSupported -> + col.note("A tap cannot reach any app. Hold and double press work.") + } + if (HwKey.SCREEN !in set) { + col.row("Advanced", "Every gesture of this button") { + startActivity( + if (HwKey.POWER in set) Intent(this, PowerActivity::class.java) + else TriggerListActivity.intent(this, set) + ) + } } - col.note("More ways to press, and two buttons together, are under Advanced > Full control.") } private fun gestureName(t: Trigger): String = when { + HwKey.POWER in t.keys && t.keys.size > 1 -> "Hold Power, then press " + (t.keys - HwKey.POWER).first().label t.type == GestureType.HOLD -> "Press and hold" + t.count == 1 -> "Tap" t.count == 2 -> if (HwKey.POWER in t.keys && !env.powerIsDirect) "Double press" else "Double tap" - else -> "Tap" + t.count == 3 -> "Triple tap" + else -> t.count.toString() + " taps" } // ---- 3. what it does ------------------------------------------------------------------------ @@ -171,6 +220,8 @@ class GuidedSetupActivity : Activity() { ActionSpec(ActionKind.SWIPE, "left", "Next page"), ActionSpec(ActionKind.SWIPE, "right", "Previous page"), ActionSpec(ActionKind.VOICE, "", "Voice typing"), + global(GlobalAction.LOCK_SCREEN), + global(GlobalAction.SCREENSHOT), ActionSpec(ActionKind.NONE, "", "Nothing - switch the button off") ) common.forEach { spec -> @@ -180,18 +231,14 @@ class GuidedSetupActivity : Activity() { } } col.row("A menu of actions", "One press, many choices") { startActivity(MenuEditActivity.intent(this, t)) } - col.row("Open an app, or something else", "The full list of actions") { - startActivity(ActionPickerActivity.intent(this, t)) + col.row("Advanced", "Every action") { + startActivity(ActionPickerActivity.intent(this, t).putExtra(ActionPickerActivity.EXTRA_NO_ASK, true)) } } // ---- 4. allow what it needs -------------------------------------------------------------------- - private fun satisfied(n: Need): Boolean = when (n) { - Need.KEY_FILTER -> Channels.isSatisfied(this, Channel.ACCESSIBILITY) - Need.ASSISTANT -> Channels.isSatisfied(this, Channel.ASSISTANT) - Need.CAMERA -> Channels.isSatisfied(this, Channel.CAMERA) - } + private fun satisfied(n: Need): Boolean = satisfied(this, n) private fun ask(n: Need) { when (n) { @@ -207,9 +254,13 @@ class GuidedSetupActivity : Activity() { } Need.CAMERA -> { Channels.setEnabled(this, Channel.CAMERA, true) - Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show() + Toast.makeText(this, "Choose Camera app, then pick Rebind", Toast.LENGTH_LONG).show() Channels.safeStart(this, Channels.claimIntent(this, Channel.CAMERA)) } + Need.SHELL_POWER -> { + PowerControl.setWanted(this, true) + startActivity(Intent(this, ShellActivity::class.java)) + } } } @@ -225,23 +276,22 @@ class GuidedSetupActivity : Activity() { val missing = plan.needs.filterNot(::satisfied) val microphone = spec.kind == ActionKind.VOICE && !Dictation.hasMicrophone(this) - if (plan.possible && missing.isEmpty() && !microphone) return go(Step.DONE) + if (plan.possible && missing.isEmpty() && !microphone) return if (askOnly) finish() else go(Step.DONE) plan.blocked?.let { col.note(it) - col.button("Choose another way to press") { go(Step.PRESS) } + col.button("Press it another way") { go(Step.PRESS) } return } - col.note(t.label() + " will do: " + spec.describe() + ". Android asks you to allow this first:") + col.note(t.label() + " will do: " + spec.describe() + ". Allow this first:") missing.forEach { n -> col.row(n.title, n.why) { ask(n) } } if (microphone) { - col.row("Allow the microphone", "For Voice typing. A speech app on this device does the listening.") { + col.row("Allow the microphone", "For Voice typing") { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } } - col.note("Tap each one. When you come back here, it is ticked off.") if (Need.KEY_FILTER in missing) { - col.note("If the switch turns itself back off: open App info, the three-dot menu, Allow restricted settings.") + col.note("If the switch turns itself off, allow restricted settings in App info.") col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) } } } @@ -258,17 +308,57 @@ class GuidedSetupActivity : Activity() { val spec = t?.let { Store.bindings(this)[it] } if (t != null && spec != null) col.note(t.label() + " now does: " + spec.describe() + ". Try it.") if (spec?.kind == ActionKind.VOICE && Dictation.engines(this).none(Dictation::isOnDevice)) { - col.note("Voice typing works best with a speech app that listens on the device. See Voice typing on the main screen.") + col.note("See Voice typing for an on-device speech app.") + } + // Some firmware sends the double press of Power to the wallet app, not to the camera app. + val powerDouble = t == Trigger(setOf(HwKey.POWER), GestureType.TAP, 2) && !env.powerIsDirect + if (powerDouble && !Channels.isSatisfied(this, Channel.WALLET)) { + col.row("The double press does nothing?", "Take the wallet place too") { + Channels.setEnabled(this, Channel.WALLET, true) + Channels.safeStart(this, Channels.claimIntent(this, Channel.WALLET)) + } } col.button("Set up another button") { - key = null + keys = emptyList() trigger = null + fromHub = false go(Step.BUTTON) } col.primaryButton("Finish") { finish() } } companion object { - fun intent(a: Activity): Intent = Intent(a, GuidedSetupActivity::class.java) + private const val EXTRA_KEYS = "keys" + private const val EXTRA_ASK = "ask" + + /** + * For a screen that has just made a binding for [t]. Opens the allow + * step when the binding needs something the user has not allowed yet. + */ + fun askIfMissing(a: Activity, t: Trigger) { + val spec = Store.bindings(a)[t] ?: return + val needsFilter = ActionRouter.requiresAccessibility(spec) || spec.kind == ActionKind.MENU + val plan = Route.plan(t, needsFilter, env(a)) + val microphone = spec.kind == ActionKind.VOICE && !Dictation.hasMicrophone(a) + if (plan.possible && plan.needs.all { satisfied(a, it) } && !microphone) return + a.startActivity(Intent(a, GuidedSetupActivity::class.java).putExtra(EXTRA_ASK, t.id)) + } + + private fun env(c: android.content.Context): Route.Env = + Route.Env(powerIsDirect = Shell.ready && PowerControl.wanted(c), shellSupported = Shell.SUPPORTED) + + private fun satisfied(c: android.content.Context, n: Need): Boolean = when (n) { + Need.KEY_FILTER -> Channels.isSatisfied(c, Channel.ACCESSIBILITY) + Need.ASSISTANT -> Channels.isSatisfied(c, Channel.ASSISTANT) + Need.CAMERA -> Channels.isSatisfied(c, Channel.CAMERA) + Need.SHELL_POWER -> env(c).powerIsDirect + } + + /** With [keys], the flow starts at "how do you press it". */ + fun intent(a: Activity, keys: List<HwKey> = emptyList()): Intent = + Intent(a, GuidedSetupActivity::class.java).putExtra(EXTRA_KEYS, keys.joinToString("+") { it.token }) + + /** The buttons in the drawing, from the top down. */ + fun drawn(c: android.content.Context): List<HwKey> = listOf(HwKey.POWER) + Device.keys(c) + HwKey.SCREEN } } diff --git a/app/src/main/java/dev/equwal/assistkey/setup/Route.kt b/app/src/main/java/dev/equwal/assistkey/setup/Route.kt index 442399d..951137e 100644 --- a/app/src/main/java/dev/equwal/assistkey/setup/Route.kt +++ b/app/src/main/java/dev/equwal/assistkey/setup/Route.kt @@ -18,23 +18,29 @@ object Route { /** One thing the user must allow. The order of the entries is the order to ask in. */ enum class Need(val title: String, val why: String) { KEY_FILTER( - "Let AssistKey see the buttons", - "Android calls this an accessibility service. AssistKey uses it to see button presses and to do the action." + "Let Rebind see the buttons", + "Android calls this an accessibility service." ), ASSISTANT( - "Make AssistKey the assistant app", - "Android sends a held Power button to the assistant app. That is how the press reaches AssistKey." + "Let Rebind take the hold of Power", + "Rebind stands in for the assistant app." ), CAMERA( - "Make AssistKey the camera app", - "Android sends a double press of Power to the camera app. That is how the press reaches AssistKey." + "Let Rebind take the double press of Power", + "Rebind stands in for the camera app. Your camera still works." + ), + SHELL_POWER( + "Open the full Power button", + "Shell access makes every Power press work." ) } /** What is true of the device and the build at this moment. */ data class Env( /** The app reads the Power button directly (shell access is on and in use). */ - val powerIsDirect: Boolean + val powerIsDirect: Boolean, + /** This build can have shell access. The Google Play build cannot. */ + val shellSupported: Boolean = false ) data class Plan( @@ -50,7 +56,16 @@ object Route { * Back, Home, a swipe, a scroll, voice typing. */ fun plan(trigger: Trigger, actionNeedsFilter: Boolean, env: Env): Plan { + if (HwKey.SCREEN in trigger.keys) { + val plainTap = trigger.keys.size == 1 && trigger.type == GestureType.TAP && trigger.count == 1 + // The key filter draws the button and carries out the action. + return if (plainTap) Plan(listOf(Need.KEY_FILTER)) + else Plan(emptyList(), "The on-screen button has one gesture: a tap.") + } val power = HwKey.POWER in trigger.keys + if (power && trigger.keys.size > 1 && trigger != Trigger.powerThen((trigger.keys - HwKey.POWER).first())) { + return Plan(emptyList(), "With Power, one way works: hold Power, then press the other button.") + } if (!power || env.powerIsDirect) return Plan(listOf(Need.KEY_FILTER)) val alone = trigger.keys.size == 1 @@ -59,7 +74,8 @@ object Route { alone && trigger.type == GestureType.TAP && trigger.count == 2 -> Need.CAMERA // Power held, then one other button: the hold opens the assistant door. !alone && trigger.keys.size == 2 && trigger.type == GestureType.TAP && trigger.count == 1 -> Need.ASSISTANT - else -> return Plan( + // No door of Android leads here. Shell access does, where the build has it. + else -> return if (env.shellSupported) Plan(listOf(Need.KEY_FILTER, Need.SHELL_POWER)) else Plan( emptyList(), "Android does not show this Power press to apps. Hold and double press work." ) @@ -68,13 +84,24 @@ object Route { return Plan(if (actionNeedsFilter || second) listOf(Need.KEY_FILTER, door) else listOf(door)) } + /** The most buttons that one combination can have. */ + const val MAX_TOGETHER = 2 + + /** + * The selection after a tap on [key] in the drawing. A tap on a selected + * button lets it go. The on-screen button cannot be part of a combination, + * so it is always alone. A tap past [MAX_TOGETHER] drops the oldest button. + */ + fun toggle(selection: List<HwKey>, key: HwKey): List<HwKey> = when { + key in selection -> selection - key + key == HwKey.SCREEN || HwKey.SCREEN in selection -> listOf(key) + else -> (selection + key).takeLast(MAX_TOGETHER) + } + /** The ways to press [keys] that can work, for the choice the user is shown. */ fun gestures(keys: Set<HwKey>, env: Env): List<Trigger> { - val all = listOf( - Trigger(keys, GestureType.TAP, 1), - Trigger(keys, GestureType.TAP, 2), - Trigger(keys, GestureType.HOLD) - ) + // Tap, double tap, triple tap and hold come first. The long tap counts come last. + val all = Trigger.allFor(keys).sortedBy { if (it.type == GestureType.TAP && it.count > 3) 1 else 0 } return all.filter { plan(it, false, env).possible } } } diff --git a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt index b3b24dc..baf2496 100644 --- a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt +++ b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt @@ -66,9 +66,9 @@ object SettingsFile { } catch (e: Exception) { throw BadFile("This is not a settings file.") } - if (root.optString("app") != APP) throw BadFile("This is not an AssistKey settings file.") + if (root.optString("app") != APP) throw BadFile("This is not a Rebind settings file.") if (root.optInt("format", -1) != FORMAT) { - throw BadFile("This settings file is from a newer version of AssistKey.") + throw BadFile("This settings file is from a newer version of Rebind.") } val files = root.optJSONObject("settings") ?: throw BadFile("This settings file is empty.") diff --git a/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt index ae540b0..290c63d 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt @@ -16,23 +16,17 @@ import android.app.AlertDialog object AccessibilityDisclosure { const val TEXT = - "AssistKey uses the Android AccessibilityService API to remap the hardware " + - "keys of this device and to carry out the actions you bind to them.\n\n" + - "With the service on, AssistKey:\n\n" + - "- receives key presses, so that it can recognise taps, holds and " + - "combinations;\n\n" + - "- does the action you chose for you: Back, Home, Recents, a swipe, a " + - "scroll;\n\n" + - "- looks at the window in front to find its scrollable area, when you " + - "use the Scroll action;\n\n" + - "- puts recognised words into the text field that has the cursor, when " + - "you use Voice typing. It never writes into password fields;\n\n" + - "- notes the name of the app in front, in memory only, so that a key " + - "can take you back to it.\n\n" + - "It does not record what you type or what is on your screen. It collects " + - "nothing, keeps nothing and sends nothing. The app has no internet " + - "permission.\n\n" + - "Agree to go to the accessibility settings of Android." + "Rebind uses the Android AccessibilityService API to remap the buttons " + + "of this device.\n\n" + + "With it on, Rebind:\n\n" + + "- sees button presses;\n" + + "- does the action you chose;\n" + + "- reads the front window only for the Scroll action;\n" + + "- puts dictated words in the focused text field, never a password " + + "field;\n" + + "- shows a small on-screen button only when you bind one;\n" + + "- remembers the front app's name, in memory only.\n\n" + + "Nothing is collected, stored or sent. The app has no internet permission." fun show(a: Activity, onAgree: () -> Unit, onDecline: () -> Unit = {}) { AlertDialog.Builder(a) 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 05a11cc..a38ffda 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt @@ -47,7 +47,7 @@ class ActionPickerActivity : Activity() { if (!pickOnly) { col.row("Now", Store.bindings(this).raw(trigger).describe(), enabled = false) col.header("More than one") - col.row("Build a menu", "This gesture opens a menu, and the menu holds as many actions as you want") { + col.row("Build a menu", "As many actions as you want") { startActivityForResult(dev.equwal.assistkey.menu.MenuEditActivity.intent(this, trigger), MENU) } dev.equwal.assistkey.menu.Menu.presets(this).forEach { (name, items) -> @@ -64,13 +64,13 @@ class ActionPickerActivity : Activity() { col.header("Light") listOf("toggle", "darker", "brighter", "system_up", "system_down").forEach { what -> val spec = ActionSpec(ActionKind.DIM, what, "") - col.row(spec.describe(), if (what == "toggle") "On is the lowest level. Off is your system brightness." else null) { + col.row(spec.describe(), if (what == "toggle") "Lowest level, or back to the system" else null) { choose(spec) } } } col.header("Typing") - col.row("Voice typing", "Speak, and the words go where the cursor is") { + col.row("Voice typing", "Speak, and the words appear") { choose(ActionSpec(ActionKind.VOICE, "", "Voice typing")) } own(col) @@ -84,6 +84,10 @@ class ActionPickerActivity : Activity() { setResult(RESULT_OK, Intent().putExtra(RESULT_SPEC, spec.toJson().toString())) } else { Store.bind(this, trigger, spec) + // Ask now for what this binding needs. The screen closes itself when nothing is missing. + if (spec.kind != ActionKind.PASS_THROUGH && !intent.getBooleanExtra(EXTRA_NO_ASK, false)) { + dev.equwal.assistkey.setup.GuidedSetupActivity.askIfMissing(this, trigger) + } } finish() } @@ -92,10 +96,10 @@ class ActionPickerActivity : Activity() { private fun basics(col: LinearLayout) { col.header("Basics") - col.row("Default behaviour", "Let the system handle this key normally") { + col.row("Default behaviour", "The system handles this button") { choose(ActionSpec.PASS) } - col.row("Do nothing", "Swallow the key - this is how you disable a button") { + col.row("Do nothing", "Switches the button off") { choose(ActionSpec.NOTHING) } } @@ -109,7 +113,7 @@ class ActionPickerActivity : Activity() { } private fun own(col: LinearLayout) { - col.header("AssistKey") + col.header("Rebind") listOf( "Recent apps (cards)" to "dev.equwal.assistkey.home.RecentsActivity", "App search" to "dev.equwal.assistkey.home.HomeActivity" @@ -130,7 +134,7 @@ class ActionPickerActivity : Activity() { private fun navigation(col: LinearLayout) { col.header("Navigation") - col.note("These need the accessibility key filter to be active.") + col.note("These need button remapping.") GlobalAction.usable().forEach { g -> col.row(g.label, null) { choose(ActionSpec(ActionKind.GLOBAL, g.name, g.label)) @@ -140,7 +144,7 @@ class ActionPickerActivity : Activity() { private fun reading(col: LinearLayout) { col.header("Page turning") - col.note("Synthetic swipes and scrolls, for reader apps that only take touch.") + col.note("For reader apps that only take touch.") listOf( "left" to "Swipe left (next page)", "right" to "Swipe right (previous page)", @@ -186,7 +190,7 @@ class ActionPickerActivity : Activity() { col.row( "Open a specific screen", - "package/class - reaches activities with no launcher icon" + "For a screen with no icon" ) { Ui.textInput(this, "Component", "com.example/com.example.SomeActivity") { v -> choose(ActionSpec(ActionKind.LAUNCH_COMPONENT, v, shortLabel(v))) @@ -199,7 +203,7 @@ class ActionPickerActivity : Activity() { } } - col.row("Send a broadcast", "For apps that listen for a custom action") { + col.row("Send a broadcast", "For apps that listen for one") { Ui.textInput(this, "Broadcast action", "com.example.ACTION") { v -> choose(ActionSpec(ActionKind.BROADCAST, v, "Broadcast " + shortLabel(v))) } @@ -235,6 +239,9 @@ class ActionPickerActivity : Activity() { companion object { private const val EXTRA_TRIGGER = "trigger" private const val EXTRA_PICK_ONLY = "pick_only" + + /** Set by the guided setup, which asks for what a binding needs by itself. */ + const val EXTRA_NO_ASK = "no_ask" private const val MENU = 7 const val RESULT_SPEC = "spec" diff --git a/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt index 808d774..5435f1d 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt @@ -3,6 +3,9 @@ package dev.equwal.assistkey.ui import android.app.Activity import android.content.Intent import android.widget.LinearLayout +import dev.equwal.assistkey.channel.Channel +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.device.Detect import dev.equwal.assistkey.device.Device import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.native.ViwoodsBridge @@ -38,15 +41,15 @@ class AdvancedActivity : Activity() { val col = Ui.page(this, "Advanced") fullControl(col) tools(col) + hacks(col) settingsFile(col) - shell(col) } /** The hardware view: a key at a time, the Power button, combinations. */ private fun fullControl(col: LinearLayout) { col.header("Full control") col.row( - "Keys", + "Buttons", Summary.keys(Device.keys(this).size, boundOnKeys()) ) { startActivity(Intent(this, KeysActivity::class.java)) } col.row( @@ -54,16 +57,20 @@ class AdvancedActivity : Activity() { Summary.power(Store.bindings(this).all().keys.count { HwKey.POWER in it.keys }) ) { startActivity(Intent(this, PowerActivity::class.java)) } col.row( - "Two-key combinations", - "Hold one key and press another" + "Two-button combinations", + "Hold one, press another" ) { startActivity(Intent(this, ChordActivity::class.java)) } } private fun tools(col: LinearLayout) { col.header("Tools") col.row( - "Key tester", - "See exactly which keys reach the filter on this device" + "Detect this device", + Summary.detected(Detect.stored(this)?.keys?.size) + ) { startActivity(Intent(this, DetectActivity::class.java)) } + col.row( + "Button tester", + "See which presses reach the app" ) { startActivity(Intent(this, KeyTesterActivity::class.java)) } val t = Store.timing(this) @@ -72,45 +79,54 @@ class AdvancedActivity : Activity() { "Multi-tap " + t.multiTapMs + " ms · hold " + t.holdMs + " ms · chord " + t.chordMs + " ms" ) { startActivity(Intent(this, TimingActivity::class.java)) } - if (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", - state = if (hidden.isEmpty()) null else "Hiding a key" - ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } - } - col.row( "Device report", - "Help get this device fully supported - you see everything before it is sent" + "Help get this device supported" ) { startActivity(Intent(this, ReportActivity::class.java)) } } private fun settingsFile(col: LinearLayout) { col.header("Settings file") - col.row( - "Export and import", - "Save your settings to a file, or share them" - ) { startActivity(Intent(this, BackupActivity::class.java)) } + col.row("Export and import") { startActivity(Intent(this, BackupActivity::class.java)) } } - private fun shell(col: LinearLayout) { - if (!Shell.SUPPORTED) return - col.header("For devices with Shizuku or root") - col.row( - "Shell access", - "Optional. More Power button gestures, and system switches", - state = Shell.describe(this) - ) { startActivity(Intent(this, ShellActivity::class.java)) } - if (Shell.ready) { + /** + * The tricks that get more out of the hardware than Android offers an app. + * They are in one place, so that a user who wants none of them can pass by. + */ + private fun hacks(col: LinearLayout) { + col.header("Hardware hacks") + fun door(title: String, hint: String, ch: Channel) = col.row( + title, hint, state = if (Channels.isSatisfied(this, ch)) "On" else "Off" + ) { startActivity(Intent(this, PowerActivity::class.java)) } + door("Hold Power", "Rebind stands in for the assistant app", Channel.ASSISTANT) + door("Double press Power", "Rebind stands in for the camera app", Channel.CAMERA) + door("Wallet button", "Rebind stands in for the wallet app", Channel.WALLET) + if (Shell.SUPPORTED) { col.row( - "Extra-dim light", - "Below the lowest the system slider allows" - ) { startActivity(Intent(this, DisplayActivity::class.java)) } + "Full Power button", + "Every Power press, through shell access", + state = Shell.describe(this) + ) { startActivity(Intent(this, ShellActivity::class.java)) } + // No light node, no extra-dim. Detection says so; do not offer it. + if (Shell.ready && Detect.stored(this)?.capability(Detect.FRONTLIGHT) != Detect.State.NO) { + col.row( + "Extra-dim light", + "Below the lowest the system slider allows" + ) { startActivity(Intent(this, DisplayActivity::class.java)) } + } } + if (Device.hasFirmwareKeyHooks) { + val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) } + col.row( + "Device button settings", + if (hidden.isEmpty()) "What this device does with each button" + else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } + + " from this app", + state = if (hidden.isEmpty()) null else "Hiding a button" + ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } + } + } private fun boundOnKeys(): Int { diff --git a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt index 56ecad8..2a815d0 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt @@ -16,7 +16,6 @@ import dev.equwal.assistkey.store.SettingsFile import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import org.json.JSONObject @@ -33,15 +32,6 @@ class BackupActivity : Activity() { const val OPEN = 2 const val FILE_NAME = "assistkey-settings.json" - const val ABOUT = - "The file holds your key bindings, timing, navigation setup, home " + - "screen and voice typing choices.\n\n" + - "It does not hold your licence, and nothing about you or your " + - "apps beyond the apps you bound to keys or put on the home " + - "screen.\n\n" + - "An import replaces the settings it contains and leaves the " + - "others as they are. A binding can open an app or send an " + - "intent, so import files only from people you trust." } override fun onResume() { @@ -51,8 +41,6 @@ class BackupActivity : Activity() { private fun build() { val col = Ui.page(this, "Export and import") - col.note("Your settings as one file. Move them to another device, or share them.") - col.more("Export and import", ABOUT) col.header("Export") col.button("Save to a file") { @@ -66,7 +54,7 @@ class BackupActivity : Activity() { start( Intent.createChooser( Intent(Intent.ACTION_SEND).setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, "AssistKey settings") + .putExtra(Intent.EXTRA_SUBJECT, "Rebind settings") .putExtra(Intent.EXTRA_TEXT, export()), "Share settings" ), @@ -83,7 +71,7 @@ class BackupActivity : Activity() { ?.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() if (clip.isNullOrBlank()) toast("The clipboard is empty") else offer(clip) } - col.note("A binding can open an app or send an intent, so import files only from people you trust.") + col.note("Import files only from people you trust.") } private fun start(i: Intent, code: Int) { @@ -132,7 +120,7 @@ class BackupActivity : Activity() { AlertDialog.Builder(this) .setTitle("Import these settings?") .setMessage( - count.toString() + " key bindings, and settings for: " + + count.toString() + " bindings, and settings for: " + settings.keys.joinToString(", ") { it.removePrefix("assistkey_").replace("assistkey", "keys") } + ".\n\nThey replace what you have now." ) diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt index 1ce1851..7fa5dad 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt @@ -7,7 +7,6 @@ import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.check import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.primaryButton import dev.equwal.assistkey.ui.Ui.row @@ -28,10 +27,10 @@ class ChordActivity : Activity() { } private fun build() { - val col = Ui.page(this, "Key combinations") - col.note("Hold one key and press the others within the chord window.") + val col = Ui.page(this, "Button combinations") + col.note("Hold one, press the others.") - col.header("Pick the keys") + col.header("Pick the buttons") HwKey.entries.filter { it.interceptable }.forEach { key -> col.check(key.label, null, key in picked) { on -> if (on) picked.add(key) else picked.remove(key) @@ -40,16 +39,15 @@ class ChordActivity : Activity() { } if (picked.size >= 2) { - col.primaryButton("Configure " + picked.joinToString(" + ") { it.label }) { + col.primaryButton("Set up " + picked.joinToString(" + ") { it.label }) { startActivity(TriggerListActivity.intent(this, picked.toSet())) } } else { - col.note("Choose at least two keys.") + col.note("Choose at least two buttons.") } existing(col) - col.more("Key combinations", ABOUT) } /** Anything already bound, so a chord is easy to find again. */ @@ -69,15 +67,4 @@ class ChordActivity : Activity() { ) { startActivity(TriggerListActivity.intent(this, keys)) } } } - - private companion object { - const val ABOUT = - "Every gesture that works on a single key works on a combination " + - "too, including multi-tap and hold.\n\n" + - "Power is absent on purpose. The system takes the Power key " + - "before any app can see it, so it can never be half of an " + - "ordinary combination.\n\n" + - "Power + Volume up is reserved by the firmware for the power " + - "menu, and AssistKey leaves it alone as a way out." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/DetectActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/DetectActivity.kt new file mode 100644 index 0000000..b824770 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/DetectActivity.kt @@ -0,0 +1,85 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.os.Build +import android.widget.LinearLayout +import dev.equwal.assistkey.device.Detect +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.primaryButton +import dev.equwal.assistkey.ui.Ui.row + +/** + * What AssistKey found on this device: the buttons, and what the device lets + * the app do. + * + * Detection is passive. The app reads Android's key tables, the input devices + * the framework lists, and - with shell access - the key codes each input node + * declares. It never asks anybody to press a button, and it records no press. + * + * "Unknown" is an answer. Android hides some settings from apps, so a state the + * app may not read says so instead of guessing. + */ +class DetectActivity : Activity() { + + private var busy = false + + override fun onResume() { + super.onResume() + Shell.connect(this) + build() + if (Detect.stored(this) == null) detect() + } + + private fun detect() { + if (busy) return + busy = true + build() + Detect.run(this) { r -> + Detect.save(this, r) + busy = false + if (!isFinishing) build() + } + } + + private fun build() { + val col = Ui.page(this, "Detect this device") + + val found = Detect.stored(this) + if (found == null) { + col.note(if (busy) "Detecting..." else "Nothing detected yet.") + col.primaryButton("Detect again") { detect() } + return + } + + col.header("This device") + col.row(found.name, found.profile + " · Android " + Build.VERSION.RELEASE) + + buttons(col, found) + capabilities(col, found) + + if (Shell.SUPPORTED && !Shell.ready) { + col.note("Shell access finds buttons Android does not declare.") + } + col.primaryButton(if (busy) "Detecting..." else "Detect again") { detect() } + } + + private fun buttons(col: LinearLayout, found: Detect.Result) { + col.header("Buttons found") + if (found.keys.isEmpty()) { + col.note("None found.") + return + } + found.keys.forEach { key -> + col.row(key.label, "Code " + key.code + (if (key.interceptable) "" else " · kept by the system")) + } + } + + private fun capabilities(col: LinearLayout, found: Detect.Result) { + col.header("What it can do") + Detect.capabilityNames.forEach { name -> + col.row(name, state = found.capability(name).label) + } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt index 7d0a2a3..1d04952 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt @@ -7,7 +7,6 @@ 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.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -30,16 +29,12 @@ class DisplayActivity : Activity() { private fun build() { val col = Ui.page(this, "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." - ) + col.note("Below the lowest the system allows.") if (!Shell.ready) { col.row( "Shell access", - "Needed to reach the light directly", + "Needed for this", state = Shell.describe(this) ) { startActivity(Intent(this, ShellActivity::class.java)) } return @@ -49,7 +44,7 @@ class DisplayActivity : Activity() { col.header("Level") col.row( "Off", - "The system slider is in charge", + "The system is in charge", state = if (current == 0) "In use" else null ) { choose(0) } ExtraDim.levels().forEach { v -> @@ -57,30 +52,17 @@ class DisplayActivity : Activity() { choose(v) } } - col.more("Extra-dim light", ABOUT) if (Device.brightnessFloor == null) { - col.note("This device's floor has not been measured, so these values are a guess.") + col.note("These values are a guess on this device.") } } - private companion object { - const val ABOUT = - "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.\n\n" + - "To change the level from a key, bind a Light action: Extra dim " + - "toggle, darker or brighter. The toggle goes to the lowest " + - "level, and back to your system brightness." - } - 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.", + "The light needs Shizuku started as root", Toast.LENGTH_LONG ).show() ExtraDim.set(this, 0) {} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt index fd621d8..f5ba60e 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt @@ -7,7 +7,6 @@ import android.content.pm.PackageManager import dev.equwal.assistkey.home.HomeActivity import dev.equwal.assistkey.home.HomeSettingsActivity import dev.equwal.assistkey.home.RecentsActivity -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.row /** The two screens AssistKey draws for itself: the home screen, and recents. */ @@ -31,27 +30,14 @@ class HomeHubActivity : Activity() { col.row( "Home screen", - "A clock, the apps you choose, and a line to type in", + "A clock, your apps, and a search line", state = if (isDefault) "In use" else if (offered) "Ready" else "Off" ) { startActivity(Intent(this, HomeSettingsActivity::class.java)) } col.row( "Recent apps", - "Card switcher made for e-ink, bindable to any key" + "Cards you can bind to a button" ) { startActivity(Intent(this, RecentsActivity::class.java)) } - col.more("Home and recents", ABOUT) - } - - private companion object { - const val ABOUT = - "The home screen keeps the app list out of sight until you type. " + - "When one match is left it opens itself. There are no icons and " + - "no grid, so there is nothing for an e-ink panel to redraw.\n\n" + - "AssistKey is not a home screen until you switch it on and the " + - "system asks you to choose.\n\n" + - "Recent apps is a row of cards like the Android switcher, drawn " + - "with outlines and no animation. Bind it to a key from the action " + - "list, under AssistKey." } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt index c1779ae..614ee9a 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt @@ -10,7 +10,6 @@ import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.engine.KeyLog import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -54,46 +53,29 @@ class KeyTesterActivity : Activity() { private fun build() { lastCount = KeyLog.count() - val col = Ui.page(this, "Key tester") + val col = Ui.page(this, "Button tester") if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) { col.row( - "Key remapping is off", - "Nothing will appear here until it is on", + "Button remapping", + "Nothing will appear here", state = "Off" ) { startActivity(android.content.Intent(this, SetupActivity::class.java)) } return } - col.note("Press a key. Anything that reaches the filter is listed below.") - col.more("Key tester", ABOUT) + col.note("Press a button.") col.button("Clear") { KeyLog.clear(); build() } val recent = KeyLog.recent() col.header(if (recent.isEmpty()) "Nothing yet" else "Most recent first") if (recent.isEmpty()) { - col.note( - "If a key never appears after pressing it, the firmware is " + - "keeping it to itself. For a volume key that usually means " + - "a firmware hook is set - see Firmware key hooks." - ) + col.note("A button that never appears is kept by the device.") return } recent.forEach { e -> col.row(e.describe(), "keycode " + e.keyCode, enabled = false) } } - - private companion object { - const val ABOUT = - "Every key that reaches the filter is listed, whether or not it " + - "has a binding.\n\n" + - "Power will never appear. The window manager takes it before " + - "any app can see it, on every Android device.\n\n" + - "Only real presses count. Events injected with adb shell input " + - "bypass accessibility filters entirely and will not show up.\n\n" + - "Keys are listed only while this screen is open, and are never " + - "stored." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt index 1bdd9cd..1709df7 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt @@ -11,8 +11,6 @@ import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.store.Store -import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.row /** @@ -29,35 +27,32 @@ class KeysActivity : Activity() { } private fun build() { - val col = Ui.page(this, "Keys") + val col = Ui.page(this, "Buttons") if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) { - col.row("Key remapping is off", "Nothing bound here can fire yet", state = "Off") { + col.row("Button remapping", "Nothing here can fire", state = "Off") { startActivity(Intent(this, SetupActivity::class.java)) } } - col.header("Keys on this device") Device.keys(this).forEach { key -> keyRow(col, key) } col.row("Power", Summary.power(powerBindings())) { startActivity(Intent(this, PowerActivity::class.java)) } - col.header("Together") col.row( - "Two-key combinations", - "Hold one key and press another - any pair or larger set" + "Two-button combinations", + "Hold one, press another" ) { startActivity(Intent(this, ChordActivity::class.java)) } - col.more("Keys", ABOUT) } private fun keyRow(col: LinearLayout, key: HwKey) { val hidden = ViwoodsBridge.hidesFromFilter(this, key) col.row( key.label, - if (hidden) "A firmware hook is hiding this key - see Firmware key hooks" + if (hidden) "See Device button settings" else boundSummary(setOf(key)), state = if (hidden) "Hidden" else null ) { startActivity(TriggerListActivity.intent(this, setOf(key))) } @@ -80,16 +75,4 @@ class KeysActivity : Activity() { private fun powerBindings(): Int = Store.bindings(this).all().keys.count { HwKey.POWER in it.keys } - - private companion object { - const val ABOUT = - "Each key has the same set of gestures: one to five taps, and press " + - "and hold. Any of them can be bound on its own.\n\n" + - "A key with no bindings is never taken from the system. A key " + - "whose highest bound tap count is one fires as soon as it is " + - "released, so you only pay the multi-tap wait on keys where you " + - "asked for a double tap.\n\n" + - "The Power button is not like the others and has a screen of its " + - "own." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt index 86fa0fa..d784163 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt @@ -47,44 +47,25 @@ class LicenseActivity : Activity() { private fun status(col: LinearLayout, state: License.State) { when (state.tier) { - License.Tier.LICENSED -> { - col.header("Unlocked") - col.note( - "This Google account owns AssistKey. It stays unlocked on every " + - "device signed in to the same account. Thank you." - ) - } + License.Tier.LICENSED -> col.header("Unlocked") License.Tier.BETA -> { col.header("Beta - free for now") - col.note( - "Everything works, at no charge, for as long as the beta is open. " + - "When it closes, remapping stops until a licence is bought. " + - "Because you were here for the beta, yours will be at the " + - "tester price." - ) + col.note("When the beta closes, remapping stops until you buy.") col.note( if (state.betaConfirmedByPlay) { "Google Play confirms the beta is open." } else { - "This build's free access ends on " + BuildConfig.BETA_EXPIRES_DATE + - " at the latest, and sooner if the beta is closed before then." + "Free until " + BuildConfig.BETA_EXPIRES_DATE + " at the latest." } ) } License.Tier.TRIAL -> { col.header("Trial - " + days(state.trialDaysLeft) + " left") - col.note( - "Everything works during the trial. After it, remapping stops " + - "until a licence is bought. Your bindings are kept either way." - ) + col.note("After it, remapping stops until you buy. Your bindings are kept.") } License.Tier.LOCKED -> { col.header("Locked") - col.note( - "Remapping is switched off and every key behaves as the firmware " + - "intends. Your bindings are kept and come back the moment the " + - "app is unlocked." - ) + col.note("Remapping is off. Your bindings are kept.") } } } @@ -97,10 +78,7 @@ class LicenseActivity : Activity() { col.header("Buy") if (PlayBilling.purchasePending) { - col.note( - "A payment is still being processed by Google Play. The app unlocks " + - "by itself when it clears; nothing more is needed." - ) + col.note("A payment is being processed. The app unlocks by itself.") } val tester = License.testerEligible(this) @@ -112,24 +90,19 @@ class LicenseActivity : Activity() { else -> { if (tester && testerPrice != null) { col.note( - "You took part in the beta, so the licence is " + testerPrice + - (if (fullPrice != null) " instead of " + fullPrice else "") + "." + "Tester price" + + (if (fullPrice != null) ", instead of " + fullPrice else "") + "." ) - col.primaryButton("Buy at the tester price - " + testerPrice) { - purchase(Sku.PRO_TESTER) - } + col.primaryButton("Buy - " + testerPrice) { purchase(Sku.PRO_TESTER) } } else if (fullPrice != null) { - col.primaryButton("Buy AssistKey - " + fullPrice) { purchase(Sku.PRO) } + col.primaryButton("Buy - " + fullPrice) { purchase(Sku.PRO) } } - col.note("One payment through Google Play. No subscription, no account.") + col.note("One payment. No subscription.") } } if (!tester) { - col.row( - "I was a beta tester", - "Enter the code you were given to get the tester price" - ) { askForCode() } + col.row("I was a beta tester", "For the tester price") { askForCode() } } } @@ -141,13 +114,10 @@ class LicenseActivity : Activity() { PlayBilling.availability == PlayBilling.Availability.UNKNOWN -> "Asking Google Play..." problem != null -> problem - state.tier == License.Tier.BETA -> - "The licence is not on sale yet. There is nothing to buy during " + - "this stage of the beta." + state.tier == License.Tier.BETA -> "Not on sale yet." else -> - "Google Play is not offering the licence to this account. If the " + - "app was installed from a file, install it from Google Play " + - "instead and try again." + "Google Play is not offering it to this account. Install the app " + + "from Google Play." } ) } @@ -178,11 +148,8 @@ class LicenseActivity : Activity() { private fun restore(col: LinearLayout) { col.header("Already bought it?") - col.note( - "Purchases belong to the Google account that made them. Sign in to that " + - "account in the Play Store, then check again." - ) - col.button("Check Google Play again") { + col.note("Sign in to the Google account that bought it.") + col.button("Check again") { Toast.makeText(this, "Checking...", Toast.LENGTH_SHORT).show() PlayBilling.refresh(this) { val tier = License.state(this).tier 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 15fcdbc..b92adff 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -13,6 +13,7 @@ import android.widget.LinearLayout import android.widget.TextView import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.device.Detect import dev.equwal.assistkey.home.HomeActivity import dev.equwal.assistkey.license.License import dev.equwal.assistkey.license.PlayBilling @@ -20,6 +21,8 @@ import dev.equwal.assistkey.model.GestureType import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.NavNative +import dev.equwal.assistkey.setup.DeviceView +import dev.equwal.assistkey.setup.GuidedSetupActivity import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.dp import dev.equwal.assistkey.ui.Ui.header @@ -34,7 +37,7 @@ import dev.equwal.assistkey.voice.Dictation * The hub, and the only screen the app opens on. * * It fits on one screen at 412 x 824 dp and says three things: what the licence - * is, the one action that sets up a button, and what is bound now. Everything + * is, and the drawing of the device that shows and sets up the buttons. Everything * else is a tile that leads to a screen of its own. * * Rebuilt in onResume rather than onCreate, because almost every setup step @@ -43,13 +46,13 @@ import dev.equwal.assistkey.voice.Dictation */ class MainActivity : Activity() { - /** How many bindings the hub lists before it points at Advanced. */ - private val listed = 4 - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Channels.syncComponents(this) dev.equwal.assistkey.shell.Shell.connect(this) + // Finds the buttons and the capabilities of this device. It runs at + // first start, and again after a firmware or app change. + Detect.refreshIfStale(this) PermissionsActivity.showOnce(this) } @@ -67,8 +70,7 @@ class MainActivity : Activity() { val col = Ui.page(this) appBar(col) col.rule() - callToAction(col) - yourButtons(col) + buttons(col) hubTiles(col) } @@ -85,7 +87,7 @@ class MainActivity : Activity() { setOnClickListener { startActivity(Intent(this@MainActivity, LicenseActivity::class.java)) } } bar.addView(TextView(this).apply { - text = "AssistKey" + text = "Rebind" setTextColor(Ui.INK) setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) setTypeface(Typeface.DEFAULT_BOLD) @@ -94,59 +96,36 @@ class MainActivity : Activity() { col.addView(bar, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) } - // ---- the one action ---------------------------------------------------- + // ---- the buttons --------------------------------------------------------- + + /** The buttons the user tapped in the drawing, oldest first. */ + private var selected: List<HwKey> = emptyList() /** - * Press a button, choose what it does. The flow finds the way in by itself, - * so nobody has to know what a channel or a role is. + * The drawing of the device is the way in. Each button shows what it does + * now. A tap selects a button, two selected buttons make a combination, and + * the button under the drawing opens the setup of that selection. */ - private fun callToAction(col: LinearLayout) { - col.primaryButton("Set up a button") { - startActivity( - Intent().setClassName(this, "dev.equwal.assistkey.setup.GuidedSetupActivity") - ) - } - col.note("Pick a button, choose what it does, and AssistKey does the rest.") - } - - // ---- what is bound now ------------------------------------------------- - - private fun yourButtons(col: LinearLayout) { - col.header("Your buttons") - val all = Store.bindings(this).all().entries - .sortedWith( - compareBy( - { t -> t.key.keys.minOf { it.ordinal } }, - { t -> t.key.keys.size }, - { t -> t.key.type.ordinal }, - { t -> t.key.count } - ) - ) - if (all.isEmpty()) { - col.note("Nothing is bound yet. Set up a button to make a start.") - return - } - val overflow = all.size > listed - val shown = all.take(if (overflow) listed - 1 else listed) - shown.forEach { (trigger, spec) -> - col.row(Summary.binding(plainGesture(trigger), spec.describe())) { - startActivity(ActionPickerActivity.intent(this, trigger)) + private fun buttons(col: LinearLayout) { + val bound = Store.bindings(this).all() + val drawn = GuidedSetupActivity.drawn(this) + col.addView(DeviceView(this, heightDp = 270).apply { + keys = drawn + selected = this@MainActivity.selected + captions = drawn.associateWith { key -> + Summary.caption(bound.filterKeys { key in it.keys }.values.map { it.describe() }) + } + onSelect = { this@MainActivity.selected = it; build() } + }) + if (selected.isEmpty()) { + col.note("Tap a button. Tap two for a combination.") + } else { + col.primaryButton("Set up " + selected.joinToString(" + ") { it.label }) { + startActivity(GuidedSetupActivity.intent(this, selected)) } - } - if (overflow) { - col.row( - (all.size - shown.size).toString() + " more", - "See them all under Advanced > Full control" - ) { startActivity(Intent(this, AdvancedActivity::class.java)) } } } - private fun plainGesture(t: Trigger): String = Summary.gesture( - t.keys.sortedBy { it.ordinal }.map { it.label }, - t.type == GestureType.HOLD, - t.count - ) - // ---- tiles ------------------------------------------------------------- private fun hubTiles(col: LinearLayout) { @@ -164,7 +143,7 @@ class MainActivity : Activity() { Ui.Tile("Setup", setupSummary()) { startActivity(Intent(this, SetupActivity::class.java)) }, - Ui.Tile("Advanced", "Every key, the Power button, testing and backup") { + Ui.Tile("Advanced", "Every button, tools and backup") { startActivity(Intent(this, AdvancedActivity::class.java)) } ) 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 78d06a7..61778a9 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt @@ -23,7 +23,6 @@ import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.check import dev.equwal.assistkey.ui.Ui.code import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -40,13 +39,13 @@ class NavigationActivity : Activity() { private data class Setup(val name: String, val buttons: Boolean, val gestures: Boolean, val keys: Boolean) private val setups = listOf( - Setup("Buttons only", true, false, false), + Setup("Bar only", true, false, false), Setup("Gestures only", false, true, false), - Setup("Power key only", false, false, true), - Setup("Power key + buttons", true, false, true), - Setup("Power key + gestures", false, true, true), - Setup("Buttons + gestures", true, true, false), - Setup("Power key + buttons + gestures", true, true, true) + Setup("Power button only", false, false, true), + Setup("Power button + bar", true, false, true), + Setup("Power button + gestures", false, true, true), + Setup("Bar + gestures", true, true, false), + Setup("Power button + bar + gestures", true, true, true) ) private var redrawnForInsets = false @@ -74,7 +73,7 @@ class NavigationActivity : Activity() { private fun choose(buttons: Boolean, gestures: Boolean, keys: Boolean) { if (!buttons && !gestures && !keys) { - Toast.makeText(this, "That would leave no way to navigate at all", Toast.LENGTH_LONG).show() + Toast.makeText(this, "Leaves no way to navigate", Toast.LENGTH_LONG).show() build() return } @@ -127,7 +126,7 @@ class NavigationActivity : Activity() { build() Shell.runAll(NavNative.shellCommands(buttons, gestures)) { ok -> applying = false - if (!ok) Toast.makeText(this, "The system refused part of that", Toast.LENGTH_LONG).show() + if (!ok) Toast.makeText(this, "The system refused", Toast.LENGTH_LONG).show() // The bar comes and goes with a relayout; give the insets a moment. window.decorView.postDelayed({ if (!isFinishing) build() }, 1200L) } @@ -149,16 +148,15 @@ class NavigationActivity : Activity() { val wantKeys = wanted("keys", keysOn()) val col = Ui.page(this, "Navigation") - col.note("The button bar, swipe gestures and the Power key, in any mix.") - col.check("Button bar", "Back, Home and Recents along the bottom edge", wantButtons) { + col.check("Button bar", "Back, Home and Recents at the bottom", wantButtons) { choose(it, wantGestures, wantKeys) } - col.check("Swipe gestures", "Swipe up for Home; without the bar, swipe in from a side for Back", wantGestures) { + col.check("Swipe gestures", "Swipe up for Home, in from a side for Back", wantGestures) { choose(wantButtons, it, wantKeys) } col.check( - "Power key", + "Power button", if (Shell.SUPPORTED) "Tap for Back, hold for Home, double tap for Recents" else "Hold for Home, double press for Recents", wantKeys @@ -166,11 +164,10 @@ class NavigationActivity : Activity() { choose(wantButtons, wantGestures, it) } - col.header("Or pick a setup") val labels = setups.map { s -> (if (s.buttons == wantButtons && s.gestures == wantGestures && s.keys == wantKeys) "* " else " ") + s.name } - col.button("Choose...") { + col.button("Pick a setup") { Ui.pick(this, "Navigation setup", labels) { i -> setups[i].let { choose(it.buttons, it.gestures, it.keys) } } @@ -181,7 +178,7 @@ class NavigationActivity : Activity() { } private fun powerKey(col: LinearLayout) { - col.header("Power key") + col.header("Power button") val b = Store.bindings(this) val direct = Shell.ready && PowerControl.wanted(this) defaults.forEach { (t, _) -> @@ -200,23 +197,17 @@ class NavigationActivity : Activity() { } col.row( if (Shell.SUPPORTED) "More Power button gestures" else "Set up the Power button", - if (Shell.SUPPORTED) "More taps, combinations with other keys, and how it works" - else "Make AssistKey the assistant for hold, and the camera app for double press" + if (Shell.SUPPORTED) "More taps and combinations" else "Hold and double press" ) { startActivity(Intent(this, PowerActivity::class.java)) } if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) { - col.row("Key remapping is off", "Back and Home cannot fire yet", state = "Off") { + col.row("Button remapping", "Nothing here can fire", state = "Off") { startActivity(Intent(this, SetupActivity::class.java)) } } if (!direct) { - col.note( - (if (Shell.SUPPORTED) "Without shell access a tap cannot reach any app. " - else "A tap cannot reach any app. ") + - "Hold and double press still work." - ) - col.more("The Power key as navigation", POWER_KEY_ABOUT) + col.note("A tap cannot reach any app. Hold and double press work.") } } @@ -227,12 +218,12 @@ class NavigationActivity : Activity() { buttonsNow: Boolean?, gesturesNow: Boolean? ) { - col.header("Button bar and gestures") + col.header("Now") col.row("Button bar", null, enabled = false, state = now(buttonsNow, "Showing", "Hidden")) - col.row("Swipe-up gesture", null, enabled = false, state = now(gesturesNow, "On", "Off")) + col.row("Swipe gestures", null, enabled = false, state = now(gesturesNow, "On", "Off")) if (applying) { - col.note("Applying... the screen may redraw once.") + col.note("Applying...") return } if (buttonsNow == wantButtons && gesturesNow == wantGestures) return @@ -242,18 +233,15 @@ class NavigationActivity : Activity() { return } if (Shell.SUPPORTED) { - col.note("Android does not let apps switch these. Shell access lets AssistKey do it from here:") + col.note("Shell access applies this from here:") col.row( "Shell access", - "Set it up once, on the device, with no computer", + "No computer needed", state = Shell.describe(this) ) { startActivity(Intent(this, ShellActivity::class.java)) } - col.note("Or, with a computer and USB debugging:") + col.note("Or from a computer with USB debugging:") } else { - col.note( - "Android does not let apps switch these. Do it once from a computer, " + - "with the device plugged in and USB debugging on. It survives restarts." - ) + col.note("Run this once from a computer with USB debugging on.") } col.code(NavNative.commands(wantButtons, wantGestures).joinToString("\n")) } @@ -263,18 +251,4 @@ class NavigationActivity : Activity() { false -> no null -> "Unknown" } - - private companion object { - const val POWER_KEY_ABOUT = - "The window manager takes the Power key before any app can see it, " + - "so a single tap cannot reach AssistKey.\n\n" + - "Press and hold arrives as an assistant request, so it works " + - "once AssistKey is the digital assistant.\n\n" + - "Double press arrives as a camera launch, so it works once " + - "AssistKey is the default camera app.\n\n" + - "Both are set up on the Power button screen.\n\n" + - "Back, Home and Recents on a Power gesture keep working when " + - "the app is locked, so a reader with no bar and no gestures is " + - "never left without a way to navigate." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt index 63fa6ee..3b4648b 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt @@ -14,7 +14,6 @@ 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.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.primaryButton import dev.equwal.assistkey.ui.Ui.row @@ -48,8 +47,8 @@ class PermissionsActivity : Activity() { val state = granted(this) val list = ArrayList<Item>() list += Item( - "Key remapping", - "The accessibility key filter. It sees the keys and carries out the actions. Everything else depends on it.", + "Button remapping", + "Everything else depends on it", state[0] ) { Channels.setEnabled(this, Channel.ACCESSIBILITY, true) @@ -61,7 +60,7 @@ class PermissionsActivity : Activity() { } list += Item( "Digital assistant", - "So that holding the Power button reaches this app.", + "For holding the Power button", state[1] ) { Channels.setEnabled(this, Channel.ASSISTANT, true) @@ -69,12 +68,12 @@ class PermissionsActivity : Activity() { } list += Item( "Microphone", - "For Voice typing only. A speech app on this device does the listening.", + "For Voice typing", state[2] ) { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } list += Item( "App usage data", - "For the recent-apps cards, to put apps in order of last use. Not needed with shell access.", + "Puts the recent apps cards in order", state[3] ) { start(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) } return list @@ -122,33 +121,23 @@ class PermissionsActivity : Activity() { val all = items() val missing = all.count { !it.granted } val col = Ui.page(this, "Permissions") - col.note("Nothing here is required. The app works with whatever you grant.") - if (missing == 0) { - col.note("Everything is granted.") - } else { + col.note("Nothing here is required.") + if (missing > 0) { col.primaryButton("Set up what is missing (" + missing + ")") { walking = true asked = HashSet() next() } - col.note("Android grants each one on a screen of its own. Come back here after each, and the next one opens.") } - col.header("One at a time") all.forEach { item -> entry(col, item) } - col.header("If a switch turns itself back off") - col.note("Android blocks some switches for apps that were installed from a file.") + col.note("If a switch turns itself off, allow restricted settings in App info.") col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) } - col.more("Restricted settings", RESTRICTED) if (!PowerNative.canWriteSecure(this)) { col.header("From a computer, once") - col.note( - "One permission cannot be granted on the device" + - (if (Shell.SUPPORTED) " without shell access" else "") + - ". It lets the app change the system's own Power button and Emergency SOS settings." - ) + col.note("It lets the app change the Power button and Emergency SOS settings.") col.code(PowerNative.GRANT_COMMAND) } } @@ -164,14 +153,6 @@ class PermissionsActivity : Activity() { private const val PREFS = "assistkey_setup" private const val K_SHOWN = "permissions_shown" - private const val RESTRICTED = - "Android blocks restricted settings for an app that was installed " + - "outside an app store, and accessibility is one of them. The " + - "symptom is silent: the switch appears to turn on, then reverts " + - "a moment later with no message.\n\n" + - "Open App info, tap the three-dot menu, choose Allow restricted " + - "settings, then try again." - /** * Whether each grant is in place, in the order this screen lists them. * The hub and the Setup screen count these, so there is one list. 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 28442bd..6f6fd99 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt @@ -19,7 +19,6 @@ import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.check import dev.equwal.assistkey.ui.Ui.code import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -68,8 +67,8 @@ class PowerActivity : Activity() { if (!Shell.ready && !PowerNative.canWriteSecure(this)) return col.header("Emergency SOS") col.check( - "System Emergency SOS on five presses", - "A safety feature of Android. Untick it if five quick presses start it by accident, or to use 5 taps for an action.", + "Start it on five presses", + "Untick to use 5 taps for an action", PowerNative.emergencySos(this) ) { on -> PowerNative.setEmergencySos(this, on) { ok -> @@ -91,77 +90,62 @@ class PowerActivity : Activity() { // ---- with shell access ---------------------------------------------------------------- private fun direct(col: LinearLayout) { - col.note("AssistKey is reading the Power button directly.") - - col.header("Taps") bindRow(col, "Tap", Trigger(power, GestureType.TAP, 1)) bindRow(col, "Double tap", Trigger(power, GestureType.TAP, 2)) bindRow(col, "Triple tap", Trigger(power, GestureType.TAP, 3)) bindRow(col, "4 taps", Trigger(power, GestureType.TAP, 4)) bindRow(col, "5 taps", Trigger(power, GestureType.TAP, 5)) + bindRow(col, "Hold", Trigger(power, GestureType.HOLD)) - col.header("Hold") - bindRow(col, "Press and hold", Trigger(power, GestureType.HOLD)) - - col.header("Power held, plus another key") + col.header("Power held, then another button") dev.equwal.assistkey.device.Device.keys(this).forEach { key -> bindRow(col, "Power + " + key.label, Trigger.powerThen(key)) } col.header("Safety") col.note("Power + Volume up always opens the power menu.") - col.more("The Power button, read directly", DIRECT_ABOUT) - col.check( - "Let AssistKey handle the Power button", - "Untick to give it back to the system and use the side doors instead", - true - ) { on -> + col.check("Let Rebind handle the Power button", null, true) { on -> PowerControl.setWanted(this, on) (ServiceHolder.service as? KeyFilterService)?.syncPower() build() } if (ServiceHolder.service == null) { - col.note("The accessibility key filter is off, so nothing here can fire yet.") + col.note("Button remapping is off.") } } // ---- without it -------------------------------------------------------------------------- private fun sideDoors(col: LinearLayout) { - col.note("Android does not show the Power button to apps. Two side doors are open.") - col.more("The two side doors", SIDE_DOORS_ABOUT) - - col.header("Hold - through the assistant door") + col.header("Hold") door(col, Channel.ASSISTANT) - bindRow(col, "Press and hold", Channel.POWER_HOLD) + bindRow(col, "Hold", Channel.POWER_HOLD) dev.equwal.assistkey.device.Device.keys(this).forEach { key -> - bindRow(col, "Power held, then " + Ui.inSentence(key), Trigger.powerThen(key)) + bindRow(col, "Power + " + key.label, Trigger.powerThen(key)) } - col.header("Double press - through the camera door") + col.header("Double press") door(col, Channel.CAMERA) bindRow(col, "Double press", Channel.POWER_DOUBLE) col.header("Tap") - col.note("A single tap cannot reach any app this way. The system's own choices are all there is.") + col.note("A tap cannot reach any app.") firmware(col) - col.header("Wallet door") + col.header("Wallet") door(col, Channel.WALLET) - col.note("Runs the double-press action from the wallet tile and lock-screen button.") - if (Shell.SUPPORTED) col.header("With Shizuku or root") if (!Shell.SUPPORTED) { // No shell access in this build: the side doors are everything. } else if (Shell.ready) { - col.check("Let AssistKey handle the Power button", "Tap, double tap, hold and more", false) { + col.check("Let Rebind handle the Power button", "Tap, double tap, hold and more", false) { PowerControl.setWanted(this, it) (ServiceHolder.service as? KeyFilterService)?.syncPower() build() } } else { - col.row("Shell access: " + Shell.describe(this), "Unlocks tap, double tap, more taps and combinations") { + col.row("Shell access", "Unlocks every Power gesture", state = Shell.describe(this)) { startActivity(Intent(this, ShellActivity::class.java)) } } @@ -177,17 +161,17 @@ class PowerActivity : Activity() { build() } if (on) { - col.row("Status: " + Channels.status(this, ch), null, enabled = false) + col.row("Status", null, enabled = false, state = Channels.status(this, ch)) if (!Channels.isSatisfied(this, ch)) col.button("Set up") { claim(ch) } } } private fun claim(ch: Channel) { if (ch == Channel.CAMERA) { - Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show() + Toast.makeText(this, "Choose Camera app, then pick Rebind", Toast.LENGTH_LONG).show() } if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) { - Toast.makeText(this, "No settings screen for that on this device", Toast.LENGTH_LONG).show() + Toast.makeText(this, "This device has no screen for that", Toast.LENGTH_LONG).show() } } @@ -195,7 +179,7 @@ class PowerActivity : Activity() { private fun firmware(col: LinearLayout) { if (!PowerNative.canWriteSecure(this)) { - col.note("Changing them needs a permission that shell access grants by itself, or this once from a computer:") + col.note("Needs shell access, or this once from a computer:") col.code(PowerNative.GRANT_COMMAND) return } @@ -208,7 +192,7 @@ class PowerActivity : Activity() { val long = PowerNative.longPressValue(this) col.row( "Hold: " + PowerNative.describe(PowerNative.longPress, long), - "Must be Digital assistant for the assistant door to open" + "Must be Digital assistant" ) { pick("Hold", PowerNative.longPress, long) { v -> applied(PowerNative.setLongPress(this, v)) } } @@ -231,30 +215,4 @@ class PowerActivity : Activity() { Toast.makeText(this, if (ok) "Applied" else "The system refused", Toast.LENGTH_SHORT).show() build() } - - private companion object { - - const val DIRECT_ABOUT = - "While anything is bound here, the system's own reactions to the " + - "Power button are switched off. A gesture you leave unbound " + - "locks the screen, as Power always did.\n\n" + - "A tap waits a moment for a second one only if double tap or " + - "more is bound. The wait is set under Advanced > Gesture " + - "timing.\n\n" + - "Power + Volume up always opens the power menu. The press that " + - "wakes the screen is never treated as a gesture.\n\n" + - "If shell access is lost - after a restart, say - the button " + - "goes back to the system until it returns." - - const val SIDE_DOORS_ABOUT = - "The window manager takes the Power key before any app can see it, " + - "so no accessibility service reaches it on any Android " + - "version. Two side doors are left.\n\n" + - "Press and hold arrives as an assistant request, so it works " + - "once AssistKey holds the digital assistant role.\n\n" + - "Double press arrives as a camera launch, so it works once " + - "AssistKey is the default camera app.\n\n" + - "While a held-then-key combination is bound, plain hold waits " + - "one second to see whether a key follows." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt index 5b32c32..1887d28 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt @@ -10,6 +10,7 @@ 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.Detect import dev.equwal.assistkey.device.Device import dev.equwal.assistkey.native.NavNative import dev.equwal.assistkey.shell.PowerControl @@ -50,26 +51,23 @@ class ReportActivity : Activity() { private fun build(text: String) { val col = Ui.page(this, "Device report") - col.note( - "Help get " + Device.name + " fully supported. Nothing is sent unless " + - "you send it, and AssistKey has no internet permission to send it with." - ) + col.note("Nothing is sent unless you send it.") col.primaryButton("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_SUBJECT, "Rebind 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)) + ?.setPrimaryClip(ClipData.newPlainText("Rebind device report", report)) Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() } col.header("The report") col.code(text) if (Shell.SUPPORTED && !Shell.ready) { - col.note("With shell access the report also lists the input devices and their keys, which is the most useful part.") + col.note("Shell access adds the input devices and their buttons.") } } @@ -78,7 +76,7 @@ class ReportActivity : Activity() { startActivity( Intent.createChooser( Intent(Intent.ACTION_SEND).setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, "AssistKey device report: " + Device.name) + .putExtra(Intent.EXTRA_SUBJECT, "Rebind device report: " + Device.name) .putExtra(Intent.EXTRA_TEXT, report), "Send report" ) @@ -92,7 +90,7 @@ class ReportActivity : Activity() { val sb = StringBuilder() fun line(k: String, v: Any?) { sb.append(k).append(": ").append(v).append('\n') } - sb.append("# AssistKey device report\n") + sb.append("# Rebind device report\n") line("app", BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")") line("manufacturer", Build.MANUFACTURER) line("brand", Build.BRAND) @@ -114,6 +112,16 @@ class ReportActivity : Activity() { line("keys shown", Device.keys(this).joinToString { it.token }) line("keys seen in tester", Device.unknownSeen(this).ifEmpty { listOf("none") }.joinToString("; ")) + // What detection found by asking the device. No key press is in this. + sb.append("\n# Detection\n") + val found = Detect.stored(this) + if (found == null) { + line("detection", "not run yet") + } else { + line("keys found", found.keys.joinToString { it.token }.ifEmpty { "none" }) + Detect.capabilityNames.forEach { name -> line(name.lowercase(), found.capability(name).label.lowercase()) } + } + if (!Shell.ready) return done(sb.toString()) // Names and shapes only. The settings query is a whitelist of key and diff --git a/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt index cdd00c5..950d70e 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt @@ -2,22 +2,19 @@ package dev.equwal.assistkey.ui import android.app.Activity import android.content.Intent -import android.widget.LinearLayout import android.widget.Toast import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.license.License 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.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row /** * The master switch, what the app has been allowed to do, and the licence. * - * Key remapping comes first because nothing else works without it. + * Button remapping comes first because nothing else works without it. */ class SetupActivity : Activity() { @@ -33,8 +30,8 @@ class SetupActivity : Activity() { val on = Channels.isEnabled(this, ch) val ok = Channels.isSatisfied(this, ch) col.check( - "Key remapping", - if (ok) "On. Your bindings can fire." else Channels.status(this, ch), + "Button remapping", + if (ok) null else Channels.status(this, ch), on ) { checked -> Channels.setEnabled(this, ch, checked) @@ -42,18 +39,16 @@ class SetupActivity : Activity() { build() } if (on && !ok) { - col.button("Turn on the key filter") { claim(ch) } - col.note("If the switch turns itself back off, Android is blocking it.") + col.button("Turn it on") { claim(ch) } + col.note("If the switch turns itself off, allow restricted settings in App info.") col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) } - col.more("Restricted settings", RESTRICTED) } val granted = PermissionsActivity.granted(this) col.row( "Permissions", - "What the app may do, and a way to grant it again", state = if (granted.all { it }) "All granted" else (granted.size - granted.count { it }).toString() + " missing" ) { startActivity(Intent(this, PermissionsActivity::class.java)) } @@ -61,17 +56,8 @@ class SetupActivity : Activity() { val s = License.state(this) col.row( "Licence", - Summary.licence(s.tier, s.trialDaysLeft), state = Summary.licenceChip(s.tier, s.trialDaysLeft) ) { startActivity(Intent(this, LicenseActivity::class.java)) } - - stuck(col) - } - - private fun stuck(col: LinearLayout) { - col.header("If you get stuck") - col.note("Power + Volume up opens the power menu, whatever else is bound.") - col.more("If you get stuck", STUCK) } private fun claim(ch: Channel) { @@ -81,28 +67,11 @@ class SetupActivity : Activity() { private fun claimNow(ch: Channel) { if (ch == Channel.CAMERA) { - Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show() + Toast.makeText(this, "Choose Camera app, then pick Rebind", Toast.LENGTH_LONG).show() } if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) { - Toast.makeText(this, "No settings screen for that on this firmware", Toast.LENGTH_LONG) + Toast.makeText(this, "This device has no screen for that", Toast.LENGTH_LONG) .show() } } - - private companion object { - - const val RESTRICTED = - "Android blocks restricted settings for an app that was installed " + - "outside an app store, and accessibility is one of them. The " + - "symptom is silent: the switch appears to turn on, then reverts " + - "a moment later with no message.\n\n" + - "Open App info, tap the three-dot menu, choose Allow restricted " + - "settings, then try again." - - const val STUCK = - "Power + Volume up always opens the power menu, whatever else is " + - "bound to those keys. It is the way out of any setup.\n\n" + - "Uninstalling AssistKey restores every key to its firmware " + - "behaviour. The app writes nothing that outlives it." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt index 32a718d..8ea66b4 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt @@ -9,7 +9,6 @@ import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -41,19 +40,11 @@ class ShellActivity : Activity() { val state = Shell.state(this) val col = Ui.page(this, "Shell access") col.row("Status", null, enabled = false, state = Shell.describe(this)) - col.note("Shizuku, a free app, gives AssistKey the shell user from the device itself.") - col.more("Shell access", ABOUT) + col.note("Shizuku, a free app, sets this up with no computer.") if (state == Shell.State.READY) { col.header("What this unlocks") - col.note( - "Power button: tap, double tap, more taps, hold, and combinations " + - "with other keys.\n" + - "Navigation: show or hide the button bar and switch system " + - "gestures, from inside the app.\n" + - "Device key settings that would otherwise need a computer." - ) - col.more("After a restart", RESTART) + col.note("Every Power button gesture, the button bar and system gestures.") return } @@ -74,9 +65,8 @@ class ShellActivity : Activity() { col.header("2. Turn on wireless debugging") col.note( - "Settings > About > tap Build number seven times to unlock Developer " + - "options. In Developer options, switch on Wireless debugging. It " + - "needs Wi-Fi to be connected, but nothing leaves the device." + "Tap Build number seven times in Settings > About. Then switch on " + + "Wireless debugging. It needs Wi-Fi." ) col.button("Open Developer options") { if (!tryStart(Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS))) { @@ -85,18 +75,14 @@ class ShellActivity : Activity() { } col.header("3. Start Shizuku") - col.note( - "In Shizuku choose Pairing, then in Wireless debugging choose Pair " + - "device with pairing code and type the code into Shizuku's " + - "notification. Back in Shizuku, press Start." - ) + col.note("In Shizuku choose Pairing, pair with the code, then press Start.") if (state != Shell.State.NOT_INSTALLED) { col.button("Open Shizuku") { packageManager.getLaunchIntentForPackage(Shell.SHIZUKU_PACKAGE)?.let { tryStart(it) } } } - col.header("4. Allow AssistKey") + col.header("4. Allow Rebind") when (state) { Shell.State.NO_PERMISSION -> col.button("Ask Shizuku for permission") { Shell.requestPermission() @@ -107,24 +93,4 @@ class ShellActivity : Activity() { private fun tryStart(i: Intent): Boolean = runCatching { startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true }.getOrDefault(false) - - private companion object { - - const val ABOUT = - "Android keeps a few things from every installed app: the Power " + - "button, the navigation bar, the system gestures, a device " + - "maker's own key settings.\n\n" + - "The shell user - the one a computer gets over USB debugging - " + - "can reach all of them. Shizuku is a free app that pairs with " + - "Android's own wireless debugging and then runs a small server " + - "as that user. So AssistKey gets the same access from the " + - "device itself, with no computer and no root.\n\n" + - "Everything that depends on shell access falls back by itself " + - "when there is none." - - const val RESTART = - "Shizuku started over wireless debugging stops when the device " + - "restarts. Until it is started again, AssistKey gives the Power " + - "button back to the system, so the button always works." - } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt index 7c773ed..fa24f90 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt @@ -11,25 +11,21 @@ import dev.equwal.assistkey.license.License */ object Summary { - /** "Power, hold" - the gesture, in the words a person would use. */ - fun gesture(keys: List<String>, hold: Boolean, taps: Int): String { - val what = when { - hold -> "hold" - taps <= 1 -> "tap" - taps == 2 -> "double tap" - taps == 3 -> "triple tap" - else -> taps.toString() + " taps" - } - return keys.joinToString(" + ") + ", " + what - } - - /** "Power, hold: Home" - one line of the "Your buttons" list. */ - fun binding(gesture: String, action: String): String = gesture + ": " + action + /** What a button does now, under its name in the drawing: "Back · Recent apps". */ + fun caption(actions: List<String>): String = + if (actions.isEmpty()) "Not set" else actions.distinct().joinToString(" · ") - /** How many gestures are bound, over how many keys. */ + /** How many gestures are bound, over how many buttons. */ fun keys(keyCount: Int, boundCount: Int): String { if (boundCount == 0) return "Nothing bound yet" - return count(boundCount, "action") + " on " + count(keyCount, "key") + return count(boundCount, "action") + " on " + count(keyCount, "button") + } + + /** The detection row: how many buttons the last detection found. */ + fun detected(keyCount: Int?): String = when { + keyCount == null -> "Not run yet" + keyCount == 0 -> "No named buttons found" + else -> count(keyCount, "button") + " found" } /** The Power button, which has its own screen. */ @@ -39,9 +35,9 @@ object Summary { /** Which ways of getting around are switched on. */ fun navigation(buttons: Boolean, gestures: Boolean, keys: Boolean): String { val parts = ArrayList<String>(3) - if (buttons) parts.add("Buttons") + if (buttons) parts.add("Bar") if (gestures) parts.add("Gestures") - if (keys) parts.add("Power key") + if (keys) parts.add("Power button") if (parts.isEmpty()) return "Nothing switched on" return parts.joinToString(" · ") } @@ -62,11 +58,11 @@ object Summary { } /** - * Setup shows the blocker first. Key remapping being off stops everything + * Setup shows the blocker first. Button remapping being off stops everything * else, so it is said before any count of permissions. */ fun setup(keyRemapping: Boolean, granted: Int, total: Int): String = when { - !keyRemapping -> "Key remapping is off" + !keyRemapping -> "Button remapping is off" granted >= total -> "All set" else -> (total - granted).toString() + " to set up" } @@ -79,14 +75,6 @@ object Summary { License.Tier.LOCKED -> "Locked" } - /** The licence line on its own screen row. */ - fun licence(tier: License.Tier, trialDaysLeft: Int): String = when (tier) { - License.Tier.LICENSED -> "Bought. Thank you." - License.Tier.BETA -> "Free while the beta is open" - License.Tier.TRIAL -> count(trialDaysLeft, "day") + " of the trial left" - License.Tier.LOCKED -> "Remapping is off until a licence is bought" - } - /** "1 key", "3 keys". */ fun count(n: Int, one: String): String = n.toString() + " " + (if (n == 1) one else one + "s") diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt index d0ec638..9c183e1 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt @@ -6,7 +6,6 @@ import dev.equwal.assistkey.engine.GestureEngine import dev.equwal.assistkey.route.ServiceHolder import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row /** @@ -30,9 +29,7 @@ class TimingActivity : Activity() { "Multi-tap window", cfg.multiTapMs, listOf(180L, 220L, 280L, 350L, 450L), - "How long to wait for another tap. Longer is easier to hit, but " + - "adds that much delay to every single tap on a key that has a " + - "double tap bound." + "Longer is easier, but delays every tap" ) { Store.setTiming(this, cfg.copy(multiTapMs = it)) } section( @@ -40,7 +37,7 @@ class TimingActivity : Activity() { "Hold threshold", cfg.holdMs, listOf(300L, 400L, 450L, 600L, 800L), - "How long a key must stay down before it counts as a hold." + "How long a press counts as a hold" ) { Store.setTiming(this, cfg.copy(holdMs = it)) } section( @@ -48,7 +45,7 @@ class TimingActivity : Activity() { "Combination window", cfg.chordMs, listOf(80L, 120L, 140L, 200L, 300L), - "How close together two keys must go down to count as a combination." + "How close together two presses count" ) { Store.setTiming(this, cfg.copy(chordMs = it)) } col.header("Defaults") @@ -64,12 +61,10 @@ class TimingActivity : Activity() { title: String, current: Long, options: List<Long>, - blurb: String, + hint: String, onPick: (Long) -> Unit ) { - col.header(title) - col.note(blurb) - col.row(title, "Tap to change", state = current.toString() + " ms") { + col.row(title, hint, state = current.toString() + " ms") { val labels = options.map { v -> (if (v == current) "* " else " ") + v + " ms" } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt index 966d3cd..5a4cc5f 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt @@ -12,8 +12,6 @@ import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.check -import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -41,56 +39,49 @@ class TriggerListActivity : Activity() { !Channels.isSatisfied(this, Channel.ACCESSIBILITY) ) { col.row( - "Key remapping is off", - "Nothing here will fire yet", + "Button remapping", + "Nothing here can fire", state = "Off" ) { startActivity(Intent(this, SetupActivity::class.java)) } } keys.filter { ViwoodsBridge.hidesFromFilter(this, it) }.forEach { k -> col.note( - "A firmware hook is set for " + k.label.lowercase() + ", so the " + - "firmware keeps that key to itself and nothing bound here " + - "can fire. Advanced > Firmware key hooks has the fix." + "The device keeps " + k.label.lowercase() + + " to itself - see Device button settings." ) } if (keys == setOf(HwKey.AI) && dev.equwal.assistkey.device.Device.isViwoods) { col.check( "In the AI or crop screen, go back", - "There the AI key returns to the app you were in, whatever is bound below", + null, dev.equwal.assistkey.device.Device.aiKeyReturns(this) ) { dev.equwal.assistkey.device.Device.setAiKeyReturns(this, it) } } if (HwKey.AI in keys) { col.note( if (ViwoodsBridge.aiHookedToUs(this)) { - "The firmware sends the AI key straight to AssistKey, so only " + - "taps of the AI key on its own can fire." + "Only taps of the AI key on its own can fire." } else { - "The firmware also opens its AI screen on every AI key press, " + - "underneath whatever is bound here. Advanced > Firmware key " + - "hooks shows how to stop that." + "The AI screen also opens - see Device button settings." } ) } val b = Store.bindings(this) - col.header("Taps") (1..Trigger.MAX_TAPS).forEach { n -> val t = Trigger(keys, GestureType.TAP, n) col.row(tapLabel(n), b.raw(t).describe()) { edit(t) } } - col.header("Hold") val hold = Trigger(keys, GestureType.HOLD) - col.row("Press and hold", b.raw(hold).describe()) { edit(hold) } + col.row("Hold", b.raw(hold).describe()) { edit(hold) } - col.more("Taps and delay", DELAY) } private fun tapLabel(n: Int) = when (n) { - 1 -> "Single tap" + 1 -> "Tap" 2 -> "Double tap" 3 -> "Triple tap" else -> n.toString() + " taps" @@ -101,13 +92,6 @@ class TriggerListActivity : Activity() { companion object { private const val EXTRA_KEYS = "keys" - private const val DELAY = - "Binding a double tap adds a short delay to the single tap, " + - "because the app has to wait and see whether a second tap " + - "follows. Leave the higher tap counts on default behaviour if " + - "you want the key to feel instant.\n\n" + - "The wait is set under Advanced > Gesture timing." - fun intent(c: Context, keys: Set<HwKey>): Intent = Intent(c, TriggerListActivity::class.java).putExtra( EXTRA_KEYS, keys.sortedBy { it.ordinal }.joinToString("+") { it.token } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt index ee52744..ecc9889 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt @@ -53,10 +53,6 @@ object Ui { /** Separator lines between rows. */ val RULE = Color.rgb(150, 150, 150) - /** A key's name mid-sentence: "volume up", but never "ai key". */ - fun inSentence(key: dev.equwal.assistkey.model.HwKey): String = - if (key == dev.equwal.assistkey.model.HwKey.AI) "the AI key" else key.label.lowercase() - fun Context.dp(v: Int): Int = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics ).toInt() @@ -287,21 +283,6 @@ object Ui { return cb } - /** - * Moves a long explanation off the screen and behind one row. Every fact - * stays; the screen stops being a wall of text. - */ - fun LinearLayout.more(dialogTitle: String, body: String) { - val a = context - row("More about this") { - AlertDialog.Builder(a) - .setTitle(dialogTitle) - .setMessage(body) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } - // ---- tiles ----------------------------------------------------------- /** One hub tile: where it leads, and what state it is in now. */ diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt index ef0cc54..763d4c7 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt @@ -25,19 +25,15 @@ class ViwoodsActivity : Activity() { } private fun build() { - val col = Ui.page(this, "Firmware key hooks") - col.note( - "The firmware has its own setting for each key. Android does not allow " + - "apps to change these, so they are shown here for information, " + - "with the adb command where one is needed." - ) + val col = Ui.page(this, "Device button settings") + col.note("Apps cannot change these. Run the command from a computer.") ViwoodsBridge.keys().forEach { keyBlock(col, it) } } private fun keyBlock(col: LinearLayout, key: HwKey) { col.header(key.label) col.row( - "Firmware setting", + "Device setting", ViwoodsBridge.describe(this, key), enabled = false, state = if (ViwoodsBridge.hidesFromFilter(this, key)) "Hiding" else "Clear" @@ -45,42 +41,22 @@ class ViwoodsActivity : Activity() { if (key == HwKey.AI) { if (ViwoodsBridge.aiHookedToUs(this)) { - col.note( - "The firmware hands every AI key press straight to AssistKey. " + - "Taps, and Power then the AI key, work cleanly. Press-and-hold " + - "and combinations with the volume keys are not available this " + - "way. To give the key back to the firmware:" - ) + col.note("Taps work; hold and combinations do not. To give the button back:") col.code(ViwoodsBridge.aiUnhookCommand()) } else { - col.note( - "With this setting the firmware opens its own AI screen on every " + - "press, whether or not AssistKey acted on it - so a binding " + - "fires on top of that screen. To own the key cleanly, point " + - "the hook at AssistKey once from a computer:" - ) + col.note("The AI screen opens on every press. To stop it:") col.code(ViwoodsBridge.aiHookCommand(this)) - col.note( - "Taps then work without the AI screen appearing. Press-and-hold " + - "and combinations with the volume keys stop working for this " + - "key, because the firmware no longer lets apps see it." - ) + col.note("Then taps work, but hold and combinations stop.") } return } if (ViwoodsBridge.hidesFromFilter(this, key)) { col.note( - "While this is set, the firmware keeps " + key.label.lowercase() + - " to itself and no app can see it - bindings made for it in " + - "AssistKey will not fire. Clear it once from a computer:" + "The device keeps " + key.label.lowercase() + " to itself. To clear it:" ) ViwoodsBridge.unsetCommand(key)?.let { col.code(it) } - col.note( - "Changing this key in the device's own key settings sets it again." - ) - } else { - col.note("Not set, so AssistKey can see this key.") + col.note("The device's own button settings set it again.") } } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt index 2436b88..d522326 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt @@ -10,7 +10,6 @@ import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.voice.Dictation import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.more import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -29,17 +28,15 @@ class VoiceActivity : Activity() { private fun build() { val col = Ui.page(this, "Voice typing") - col.note("Press a key, speak, and the words go where the cursor is.") - col.more("Voice typing", ABOUT) + col.note("Press a button, speak, and the words appear.") - col.header("1. Microphone") if (Dictation.hasMicrophone(this)) { col.row("Microphone", null, enabled = false, state = "Allowed") } else { col.button("Allow the microphone") { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } - col.note("If nothing happens, allow it in App info > Permissions.") + col.note("If nothing happens, allow it in App info.") col.button("Open App info") { runCatching { startActivity( @@ -49,7 +46,7 @@ class VoiceActivity : Activity() { } } - col.header("2. Speech recognition app") + col.header("Speech app") val engines = Dictation.engines(this) val current = Dictation.engine(this) engines.forEach { e -> @@ -61,10 +58,7 @@ class VoiceActivity : Activity() { } } if (engines.none(Dictation::isOnDevice)) { - col.note( - "No app on this device recognises speech offline. Whisper, from " + - "F-Droid, is free and works with no connection." - ) + col.note("No app here recognises speech offline. Whisper, from F-Droid, is free.") col.button("Get Whisper") { val pages = listOf( "market://details?id=" + Dictation.WHISPER_PACKAGE, @@ -74,45 +68,27 @@ class VoiceActivity : Activity() { runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }.isSuccess } } - col.note( - "In Whisper, download the multilingual model and allow the microphone. " + - "Then come back here: it appears in this list." - ) + col.note("In Whisper, download a model and allow the microphone.") } - col.header("3. Language") + col.header("Language") val lang = Dictation.language(this) col.row( if (lang.isEmpty()) "Detect the language" else lang, - "Or force one: a tag such as en-US or de-DE" + "A tag such as en-US" ) { Ui.textInput(this, "Language tag", "en-US", lang) { Dictation.setLanguage(this, it); build() } } if (lang.isNotEmpty()) col.button("Clear the language") { Dictation.setLanguage(this, ""); build() } - col.header("4. Key") - col.note("Set up a button, choose a gesture, then Typing > Voice typing.") + col.header("Button") + col.note("Set up a button, then choose Voice typing.") if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) { col.row( - "Key remapping is off", - "Voice typing needs it to reach the text field", + "Button remapping", + "Voice typing needs it", state = "Off" ) { startActivity(Intent(this, SetupActivity::class.java)) } } - col.note("Voice typing never writes into password fields.") - } - - private companion object { - const val ABOUT = - "Bind the Voice typing action to a key. Press the key, speak, and " + - "the words go where the cursor is. Press the key again to stop " + - "early.\n\n" + - "AssistKey has no speech engine and no internet access. A " + - "speech recognition app on this device does the listening, and " + - "AssistKey only receives the text.\n\n" + - "Leave the language empty to let the speech app detect it. " + - "Whisper detects it from what you say, so you can change " + - "language from one sentence to the next.\n\n" + - "Voice typing never writes into a password field." } } diff --git a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt index d018c9b..a984251 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt @@ -110,8 +110,8 @@ object Dictation { it() return true } - if (!hasMicrophone(svc)) return fail(svc, "Voice typing needs the microphone. Open AssistKey > Voice typing.") - if (!available(svc)) return fail(svc, "No speech recognition app on this device. Open AssistKey > Voice typing.") + if (!hasMicrophone(svc)) return fail(svc, "Voice typing needs the microphone") + if (!available(svc)) return fail(svc, "No speech app on this device") return runCatching { svc.startActivity( Intent(svc, DictationActivity::class.java) diff --git a/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt b/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt index b2aa443..fbf931b 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt @@ -80,7 +80,7 @@ class DictationActivity : Activity() { ?: SpeechRecognizer.createSpeechRecognizer(this) }.getOrNull() if (r == null) { - Dictation.toast(this, "The speech recognition app did not start.") + Dictation.toast(this, "The speech app did not start") return done(null) } recognizer = r @@ -107,10 +107,10 @@ class DictationActivity : Activity() { this@DictationActivity, when (error) { SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Heard nothing" - SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "The microphone permission is missing" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "The microphone is not allowed" SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> - "The speech recognition app could not reach its server" - else -> "Speech recognition failed (" + error + ")" + "The speech app has no connection" + else -> "Speech failed (" + error + ")" } ) done(null) @@ -149,7 +149,7 @@ class DictationActivity : Activity() { when (TextInsert.insert(svc, text)) { TextInsert.Result.DONE -> Unit TextInsert.Result.NO_FIELD -> Dictation.toast(app, "No text field has the cursor") - TextInsert.Result.PASSWORD -> Dictation.toast(app, "Voice typing does not write into password fields") + TextInsert.Result.PASSWORD -> Dictation.toast(app, "Not into a password field") TextInsert.Result.REFUSED -> Dictation.toast(app, "That field did not accept the text") } }, INSERT_DELAY_MS) diff --git a/app/src/main/java/dev/equwal/assistkey/voice/TextInsert.kt b/app/src/main/java/dev/equwal/assistkey/voice/TextInsert.kt index c11009e..5a68875 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/TextInsert.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/TextInsert.kt @@ -53,7 +53,7 @@ object TextInsert { // Web pages and some custom fields accept a paste but not a direct edit. val clipboard = svc.getSystemService(ClipboardManager::class.java) ?: return Result.REFUSED - clipboard.setPrimaryClip(ClipData.newPlainText("AssistKey voice typing", piece)) + clipboard.setPrimaryClip(ClipData.newPlainText("Rebind voice typing", piece)) return if (node.performAction(AccessibilityNodeInfo.ACTION_PASTE)) Result.DONE else Result.REFUSED } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08be732..c67cfa4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name">AssistKey</string> - <string name="service_label">AssistKey key filter</string> - <string name="service_description">Reads hardware key presses so the AI key and the volume keys can be remapped, and performs navigation actions such as Back and Recents on your behalf. Nothing is collected or transmitted.</string> - <string name="wallet_label">AssistKey</string> - <string name="wallet_short">AssistKey</string> - <string name="wallet_long">AssistKey button actions</string> + <string name="app_name">Rebind</string> + <string name="service_label">Rebind button remapping</string> + <string name="service_description">Sees button presses and does the action you chose. Nothing is collected, stored or sent.</string> + <string name="wallet_label">Rebind</string> + <string name="wallet_short">Rebind</string> + <string name="wallet_long">Rebind button actions</string> </resources> diff --git a/app/src/test/java/dev/equwal/assistkey/device/DetectTest.kt b/app/src/test/java/dev/equwal/assistkey/device/DetectTest.kt new file mode 100644 index 0000000..1f056c5 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/device/DetectTest.kt @@ -0,0 +1,166 @@ +package dev.equwal.assistkey.device + +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.store.SettingsFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class DetectTest { + + // ---- the stored result, round trip ------------------------------------ + + private fun randomText(r: Random): String { + val alphabet = "abcXYZ 09_-./:\"\\{}[]\n\u00e9\u4e2d\u2603" + return (0 until r.nextInt(14)).map { alphabet[r.nextInt(alphabet.length)] }.joinToString("") + } + + private fun randomResult(r: Random): Detect.Result = Detect.Result( + fingerprint = randomText(r), + appVersion = randomText(r), + name = randomText(r), + profile = randomText(r), + keys = Detect.ordered(HwKey.entries.filter { r.nextBoolean() }), + capabilities = Detect.capabilityNames + .filter { r.nextBoolean() } + .associateWith { Detect.State.entries[r.nextInt(Detect.State.entries.size)] } + ) + + @Test fun `decode gives back what encode was given, for 500 random results`() { + val r = Random(20260921) + repeat(500) { + val found = randomResult(r) + assertEquals(found, Detect.decode(Detect.encode(found))) + } + } + + @Test fun `text that is not a result is null, not an exception`() { + listOf("", "hello", "{", "[1,2,3]", "null", "{}", "{\"format\":99}") + .forEach { text -> assertNull(text, Detect.decode(text)) } + } + + @Test fun `a result of the right format but the wrong shape is empty, not null`() { + val found = Detect.decode("{\"format\":1,\"keys\":\"not an array\"}")!! + assertEquals(emptyList<HwKey>(), found.keys) + assertTrue(found.capabilities.isEmpty()) + } + + @Test fun `a key or a capability this build does not know is dropped`() { + val text = "{\"format\":1,\"fingerprint\":\"f\",\"app\":\"1.0\",\"name\":\"n\"," + + "\"profile\":\"GENERIC\",\"keys\":[\"vol_up\",\"jog_wheel\"]," + + "\"capabilities\":{\"Shell access\":\"YES\",\"Teleport\":\"YES\",\"Key filter\":\"MAYBE\"}}" + val found = Detect.decode(text)!! + assertEquals(listOf(HwKey.VOL_UP), found.keys) + assertEquals(mapOf(Detect.SHELL_ACCESS to Detect.State.YES), found.capabilities) + assertEquals(Detect.State.UNKNOWN, found.capability(Detect.KEY_FILTER)) + } + + /** A device profile describes one device. It must never travel in an export. */ + @Test fun `the detection result is not a setting`() { + assertFalse(SettingsFile.ALLOWED.containsKey(Detect.PREFS)) + assertFalse(SettingsFile.allowed(Detect.PREFS, "result")) + } + + // ---- getevent ---------------------------------------------------------- + + private val geteventSample = """ + add device 1: /dev/input/event0 + name: "gpio-keys" + events: + KEY (0001): 0072 0073 0074 + input props: + <none> + add device 2: /dev/input/event1 + name: "aw9523-key" + events: + KEY (0001): 003b + add device 3: /dev/input/event2 + name: "fts_ts" + events: + KEY (0001): 0066 014a + ABS (0003): 0035 : value 0, min 0, max 1080, fuzz 0, flat 0, resolution 0 + 0036 : value 0, min 0, max 1920, fuzz 0, flat 0, resolution 0 + input props: + INPUT_PROP_DIRECT + """.trimIndent() + + @Test fun `a real getevent listing gives the keys of every node`() { + assertEquals( + setOf(0x72, 0x73, 0x74, 0x3b, 0x66, 0x14a), + Detect.scanCodesFromGetevent(geteventSample) + ) + assertEquals( + listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN, HwKey.POWER), + Detect.keysFromGetevent(geteventSample) + ) + } + + @Test fun `the touchscreen coordinates are not read as keys`() { + // 0035 and 0036 belong to ABS, and would be F1-ish numbers if taken. + assertFalse(Detect.scanCodesFromGetevent(geteventSample).contains(0x35)) + assertFalse(Detect.scanCodesFromGetevent(geteventSample).contains(0x36)) + } + + @Test fun `a node with no shell access gives nothing`() { + assertEquals(emptyList<HwKey>(), Detect.keysFromGetevent("no shell access")) + assertEquals(emptyList<HwKey>(), Detect.keysFromGetevent("")) + } + + private fun randomLine(r: Random): String { + val words = listOf( + "add device 4: /dev/input/event9", "name:", "KEY (0001):", "ABS (0003):", + "0072", "003b", "ffff", "0000", "zzzz", "-1", "0x74", " ", "\t", + "input props:", "<none>", "value 0, min 0", "\u2603", "99999999", ":", + "KEY (0001): 0072 0073", "0247", "0210", "00e2" + ) + return (0 until r.nextInt(6)).joinToString(" ") { words[r.nextInt(words.size)] } + } + + @Test fun `the parser never throws, and names only keys this app knows`() { + val r = Random(7301) + val known = HwKey.entries.toSet() + repeat(2000) { + val text = (0 until r.nextInt(12)).joinToString("\n") { randomLine(r) } + val keys = Detect.keysFromGetevent(text) + assertTrue(text, keys.all { it in known }) + assertEquals(text, keys.distinct(), keys) + assertEquals(text, Detect.ordered(keys), keys) + } + } + + @Test fun `every physical key this app can bind has one scan code of its own`() { + // The on-screen button is not a physical key. It has no scan code. + val physical = HwKey.entries.toSet() - HwKey.SCREEN + assertEquals(physical, Detect.scanCodes.keys) + assertEquals(physical.size, Detect.scanCodes.values.toSet().size) + } + + // ---- the shell probe sections ----------------------------------------- + + private val probeSample = geteventSample + "\n@@nav\n2\n@@light\npresent\n" + + @Test fun `each part of the probe output is read on its own`() { + assertEquals(listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN, HwKey.POWER), + Detect.keysFromGetevent(probeSample.substringBefore(Detect.MARK))) + assertEquals(2, Detect.section(probeSample, "nav").trim().toInt()) + assertTrue(Detect.section(probeSample, "light").contains("present")) + } + + @Test fun `a missing section is empty, whatever the text is`() { + assertEquals("", Detect.section(probeSample, "nothing")) + assertEquals("", Detect.section("", "nav")) + assertEquals("", Detect.section("@@", "nav")) + assertEquals("", Detect.section("@@nav", "nav")) + } + + // ---- ordering ---------------------------------------------------------- + + @Test fun `keys come back once each, in the order the app lists them`() { + val mixed = listOf(HwKey.POWER, HwKey.AI, HwKey.POWER, HwKey.VOL_UP, HwKey.AI) + assertEquals(listOf(HwKey.AI, HwKey.VOL_UP, HwKey.POWER), Detect.ordered(mixed)) + assertEquals(emptyList<HwKey>(), Detect.ordered(emptyList())) + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt b/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt new file mode 100644 index 0000000..3ede8e0 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt @@ -0,0 +1,30 @@ +package dev.equwal.assistkey.home + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class RecentsOpenTest { + + @Test fun `a closed app stays off the list until it is opened again`() { + val used = mapOf("reader" to 100L, "notes" to 200L, "mail" to 300L) + assertEquals(listOf("mail", "notes", "reader"), Apps.open(used, emptyMap())) + assertEquals(listOf("mail", "reader"), Apps.open(used, mapOf("notes" to 250L))) + // Opened again after the close: back on the list. + assertEquals(listOf("mail", "notes", "reader"), Apps.open(used, mapOf("notes" to 150L))) + } + + @Test fun `the list is newest first, has no app twice, and has no app closed after its last use`() { + val r = Random(11) + repeat(500) { + val used = (0 until r.nextInt(8)).associate { "app" + r.nextInt(10) to r.nextInt(1000).toLong() } + val closed = (0 until r.nextInt(6)).associate { "app" + r.nextInt(10) to r.nextInt(1000).toLong() } + val shown = Apps.open(used, closed) + assertEquals(shown.distinct(), shown) + assertEquals(shown.sortedByDescending { used.getValue(it) }.map { used.getValue(it) }, shown.map { used.getValue(it) }) + assertTrue(shown.all { used.getValue(it) > (closed[it] ?: Long.MIN_VALUE) }) + assertTrue(used.keys.filter { it !in shown }.all { used.getValue(it) <= closed.getValue(it) }) + } + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt b/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt index a5b8601..5a29a88 100644 --- a/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt +++ b/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt @@ -61,7 +61,73 @@ class RouteTest { listOf(Trigger(power, GestureType.TAP, 2), Trigger(power, GestureType.HOLD)), Route.gestures(power, sideDoors) ) - assertEquals(3, Route.gestures(power, direct).size) - assertEquals(3, Route.gestures(setOf(HwKey.VOL_UP), sideDoors).size) + assertEquals(Trigger.MAX_TAPS + 1, Route.gestures(power, direct).size) + val volume = Route.gestures(setOf(HwKey.VOL_UP), sideDoors) + assertEquals(Trigger.MAX_TAPS + 1, volume.size) + // Tap, double tap, tr [diff truncated]