commit 54edd309f4bf40127ca0c968a656c2b567e748bd truex <truex@equwal.com> 2026-09-20 16:30:11 -0700 Prepare the 0.0.1-alpha Play release; fix pages sliding under the system bars play/ holds everything the Play Console asks for: a step-by-step checklist (account, app signing with the existing key, the three products and their prices, how the beta is run and ended), the store listing, the privacy policy, the content declarations including the accessibility one, and the graphics. Pricing is US$2.99, matching Button Mapper Pro, the nearest comparable app, with US$1.49 for beta testers. The reader cannot take its own screenshots: the e-ink panel is driven outside SurfaceFlinger and `screencap` returns a black frame on every display id, as root too. src/debug adds a hook, absent from release builds, that has each screen draw its own view tree to a PNG. The store screenshots come from that. Those screenshots showed a real bug. From Android 15 an app targeting SDK 35+ is laid out edge to edge, so the top of every page - its title and intro - was hidden underneath the action bar. The theme is now NoActionBar, since each page draws its own title anyway, and Ui.page pads for the system bars. README rewritten around what was measured on the device. CHANGELOG added.
CHANGELOG.md | 25 ++++ README.md | 106 ++++++++++++-- app/src/debug/AndroidManifest.xml | 10 ++ .../java/dev/equwal/assistkey/debug/ShotApp.kt | 52 +++++++ app/src/main/java/dev/equwal/assistkey/ui/Ui.kt | 13 ++ app/src/main/res/values/styles.xml | 11 +- play/CHECKLIST.md | 161 +++++++++++++++++++++ play/DECLARATIONS.md | 90 ++++++++++++ play/LISTING.md | 94 ++++++++++++ play/PRIVACY.md | 65 +++++++++ play/graphics/Render.java | 80 ++++++++++ play/graphics/feature-1024x500.png | Bin 0 -> 28284 bytes play/graphics/icon-512.png | Bin 0 -> 11070 bytes play/graphics/screenshot-1-main.png | Bin 0 -> 141832 bytes play/graphics/screenshot-2-triggerlist.png | Bin 0 -> 67023 bytes play/graphics/screenshot-3-actionpicker.png | Bin 0 -> 85164 bytes play/graphics/screenshot-4-power.png | Bin 0 -> 136367 bytes play/graphics/screenshot-5-license.png | Bin 0 -> 79359 bytes play/graphics/screenshot-6-viwoods.png | Bin 0 -> 80713 bytes 19 files changed, 693 insertions(+), 14 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83fc413 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 0.0.1-alpha — 2026-09-20 + +First alpha. Version code 1. + +- Remaps the AI key and both volume keys through an accessibility key filter: + 1–5 taps, press-and-hold, and key combinations, each bound separately. +- Remaps Power: hold (as the digital assistant), double press (as the default + camera app), and the firmware short-press behaviour. +- Actions: Viwoods AI screens, navigation, page-turn swipes and scrolls, + volume and media, open an app or component, send an intent or broadcast. +- Licensing through Google Play Billing: licensed, beta, 7-day trial, locked. + The beta is opened and closed from Play Console; testers get a lower price. +- In-app accessibility disclosure with explicit consent. +- Key tester, and a read-only view of the firmware's own key hooks. +- No `INTERNET` permission. + +Known limits: + +- Purchases are untested: Play Billing cannot be exercised until the app exists + in Play Console. Everything up to the purchase sheet is tested on the device. +- A volume key whose firmware hook is set is invisible to every app, and only + adb can unset it. +- The reader ships with Google Play disabled; buying needs it switched on. diff --git a/README.md b/README.md index 69caff5..c6b25fc 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,13 @@ Remaps the four hardware keys on a **Viwoods AiPaper Reader** — the AI key, bo volume keys, and the power button — to arbitrary actions, including multi-tap, press-and-hold and key combinations. -Built against firmware 1.5.6 (Android 16, SDK 36). No dependencies beyond the -Android framework; the APK is about 2 MB and everything in it is in this repo. +Built against firmware 1.5.6 (Android 16, SDK 36). One dependency, Google's +Play Billing Library, and only because there is no other way to sell on Play. +The app holds no `INTERNET` permission. + +Sold on Google Play as a free download with a one-time licence. Publishing, +products, pricing and how the beta is run and ended are in +[play/CHECKLIST.md](play/CHECKLIST.md). ## Why it is shaped like this @@ -19,8 +24,41 @@ a key press can be made to arrive at this app. You tick the ones you want. | **Accessibility key filter** | AI key, Volume up, Volume down | `AccessibilityService.onKeyEvent` with `flagRequestFilterKeyEvents` | Service enabled in Settings | | **Digital assistant** | Power — press and hold | Holds `ROLE_ASSISTANT`; the firmware fires `ACTION_ASSIST` at the role holder | Role granted; firmware long-press set to Assistant | | **Camera app** | Power — double press | Becomes the default camera, so the double-press camera gesture lands here | Set as default camera app | -| **Wallet app** | Power — double press, wallet tile, lock-screen wallet button | Holds `ROLE_WALLET` and serves a (empty) `QuickAccessWalletService` | Role granted | -| **Viwoods native hooks** | AI key, Volume up, Volume down | Rewrites the firmware's own `Settings.System` key bindings | `WRITE_SETTINGS` (grantable in-app) | +| **Wallet app** | Wallet tile, lock-screen wallet button; double-press Power on firmware that targets the wallet | Holds `ROLE_WALLET` and serves an (empty) `QuickAccessWalletService` | Role granted | + +### What was measured, and how + +`adb shell input keyevent` is useless for this: injected events skip the input +filter stage, so they say nothing about what a real press does. Everything +below was measured as root with `sendevent` on the kernel input nodes, which +enters the pipeline exactly where the hardware does. + +| Press | Node | Result | +|---|---|---| +| AI key (`KEY_F1`) | `event5` "AI KEY" | Reaches the accessibility filter, whatever `CustomAiKey` holds | +| Volume up / down | `event1` / `event2` | Reaches the filter **only while its firmware hook is unset** | +| Power, held 700 ms | `event1` | Firmware fires `ACTION_ASSIST` at the assistant role holder — us | +| Power, twice within 300 ms | `event1` | `GestureLauncherService` fires `STILL_IMAGE_CAMERA`; never the wallet | + +### Firmware hooks can hide a key + +The firmware has its own per-key settings in `Settings.System`: +`CustomAiKey`, `CustomVolumeUpKey`, `CustomVolumeDownKey`. While a volume hook +holds **any** value — even its default token, `volume_up` — the firmware deals +with that key before the filter stage and no app ever sees it. Unset, the key +arrives normally. The device's own key-settings screen sets them. + +An ordinary app cannot write these. `SettingsProvider` rejects any +`Settings.System` name outside its public list unless the caller is a +privileged system app, with or without `WRITE_SECURE_SETTINGS`: +*"You cannot keep your settings in the secure settings."* So +*Advanced → Firmware key hooks* is read-only: it shows each hook, says when one +is hiding a key, and gives the fix: + +```bash +adb shell settings delete system CustomVolumeUpKey +adb shell settings delete system CustomVolumeDownKey +``` ### The power button is not like the others @@ -31,8 +69,9 @@ Android version, can see it. That leaves exactly three reachable slots: - **Short press** — firmware only. The app rewrites `Settings.Global.power_button_short_press`, so it can become Home, or nothing, instead of sleep. No app code runs. -- **Double press** — arrives as a camera or wallet launch, depending on what the - firmware's double-press target is set to. Both channels are offered. +- **Double press** — arrives as a camera launch. With more than one camera app + installed Android shows a chooser the first time; pick AssistKey and + *Always*. - **Press and hold** — arrives as an assistant request. No multi-tap beyond two, and Power can never be half of a combination. @@ -69,7 +108,11 @@ Parity with the stock Viwoods key screen, plus everything it does not offer: ## Installing -Grab `app-release.apk` from the release build and sideload it. +From Google Play, or sideload `AssistKey-<version>.apk` from the GitHub +release. Both are the same build with the same signature. + +On a Viwoods reader Google Play is switched off out of the box. The app works +without it; buying a licence does not. ### The accessibility switch will not stay on until you do this @@ -113,12 +156,43 @@ upstream by the window manager never reaches any app and simply never appears. Note that events injected with `adb shell input keyevent` **bypass** accessibility input filters entirely, so only real presses tell you anything. +## Licensing + +Four tiers, tried in order (`license/License.kt`): + +| Tier | When | Effect | +|---|---|---| +| Licensed | Play reports `assistkey_pro` or `assistkey_pro_tester` owned | Everything works; cached, so it survives being offline | +| Beta | The beta is open | Everything works, free; the install marks itself as a tester | +| Trial | Beta closed, under 7 days since first launch | Everything works | +| Locked | Otherwise | Key filter and entry points go inert; bindings are kept | + +"Is the beta open" is answered by Google Play, not by a server. A third +product, `assistkey_beta_open`, is never sold and exists only as a flag: +deactivate it in Play Console and the beta ends everywhere. Only "paid product +visible **and** flag missing", in one response, reads as closed — so a failed +or empty answer can never lock anyone out. Installs that cannot reach Play fall +back to `assistkey.betaExpires` in `gradle.properties`. + +Testers are then offered `assistkey_pro_tester`, the same licence for less. A +tester on a new device types the tester code instead; only its SHA-256 ships. + +The billing library's telemetry runtime (`datatransport`) is excluded from the +build, because it would merge `INTERNET` into the manifest. The library wraps +that runtime's start-up in a catch-all and logs *"Skipping logging since +initialization failed"*; that was confirmed in bytecode and on the device. +**Re-check it before bumping the billing version.** + ## Building ```bash -./gradlew assembleRelease +./gradlew assembleRelease bundleRelease ``` +`assembleRelease` makes the APK for testers, `bundleRelease` the `.aab` for +Play. The version comes from `gradle.properties`; `versionCode` must rise with +every upload. + Release signing is read from `keystore.properties` at the repo root, which is not committed: @@ -127,6 +201,7 @@ storeFile=assistkey-release.jks storePassword=… keyAlias=assistkey keyPassword=… +testerCode=… ``` Without it the release build still runs and produces an unsigned APK, so a fresh @@ -142,18 +217,23 @@ such as `C:\tmp` and it goes away. ## Uninstalling cleanly -Uninstalling restores every key to firmware default **except** the ones written -through the Viwoods channel — those live in system settings and outlive the app. -Reset them from *Firmware key hooks → Restore all three keys to factory* first. +Uninstalling restores every key to its firmware behaviour. The app writes +nothing that outlives it, unless you granted `WRITE_SECURE_SETTINGS` and changed +the firmware power switches, which are ordinary system settings. ## Layout ``` model/ keys, triggers, action specs, the recovered Viwoods component list engine/ the gesture state machine and the accessibility service -channel/ the five capture channels and their entry points -native/ firmware settings: power gestures and the Viwoods key hooks +channel/ the four capture channels and their entry points +license/ licence tiers and Google Play Billing +native/ firmware settings: power gestures, and the read-only key hooks route/ turning an action spec into behaviour store/ persistence, with the key-event hot path precomputed ui/ the configuration screens, built in code + +play/ everything for the Play Console: checklist, listing, policy, graphics +src/debug/ a debug-only hook that renders each screen to a PNG, because the + e-ink panel defeats `adb shell screencap` ``` diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d990071 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Debug builds only. The reader's e-ink panel is driven outside + SurfaceFlinger, so `adb shell screencap` returns a black frame; ShotApp has + each screen draw its own view tree to a PNG instead. None of this is in a + release build. +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application android:name=".debug.ShotApp" /> +</manifest> diff --git a/app/src/debug/java/dev/equwal/assistkey/debug/ShotApp.kt b/app/src/debug/java/dev/equwal/assistkey/debug/ShotApp.kt new file mode 100644 index 0000000..03e09b6 --- /dev/null +++ b/app/src/debug/java/dev/equwal/assistkey/debug/ShotApp.kt @@ -0,0 +1,52 @@ +package dev.equwal.assistkey.debug + +import android.app.Activity +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.io.File + +/** + * Store screenshots, for a device that cannot take them. + * + * A second after any screen comes to the front, its view tree is drawn into a + * bitmap and written to the app's external files directory: + * + * adb pull /sdcard/Android/data/dev.equwal.assistkey.debug/files/shots + */ +class ShotApp : Application() { + + override fun onCreate() { + super.onCreate() + val main = Handler(Looper.getMainLooper()) + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityResumed(a: Activity) { + main.postDelayed({ if (!a.isFinishing) shoot(a) }, 1200L) + } + override fun onActivityCreated(a: Activity, b: Bundle?) = Unit + override fun onActivityStarted(a: Activity) = Unit + override fun onActivityPaused(a: Activity) = Unit + override fun onActivityStopped(a: Activity) = Unit + override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit + override fun onActivityDestroyed(a: Activity) = Unit + }) + } + + private fun shoot(a: Activity) { + val v = a.window.decorView + if (v.width == 0 || v.height == 0) return + val bmp = Bitmap.createBitmap(v.width, v.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + canvas.drawColor(Color.WHITE) + v.draw(canvas) + val dir = File(getExternalFilesDir(null), "shots").apply { mkdirs() } + val out = File(dir, a.javaClass.simpleName + ".png") + out.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } + Log.i("AssistKey", "shot " + out.absolutePath) + } +} 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 a47a81b..51aca82 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt @@ -9,6 +9,7 @@ import android.text.InputType import android.util.TypedValue import android.view.Gravity import android.view.View +import android.view.WindowInsets import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.Button @@ -46,8 +47,20 @@ object Ui { isFillViewport = true setBackgroundColor(Color.WHITE) addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + // Edge-to-edge is enforced from Android 15, so the status and + // navigation bars overlap the window. Pad by whatever they cover; + // the scrolling content still runs underneath. + clipToPadding = false + setOnApplyWindowInsetsListener { v, insets -> + val bars = insets.getInsets( + WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() + ) + v.setPadding(bars.left, bars.top, bars.right, bars.bottom) + insets + } } a.setContentView(scroll) + scroll.requestApplyInsets() return col } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d79e9e5..6826a70 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,8 +1,17 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <!-- E-ink: light, high contrast, no animation anywhere. --> - <style name="Theme.App" parent="@android:style/Theme.DeviceDefault.Light"> + <!-- + No action bar: every page draws its own title, and from Android 15 an + app targeting SDK 35+ is laid out edge to edge, which slid the top of + each page underneath the bar. Ui.page pads for the system bars itself. + --> + <style name="Theme.App" parent="@android:style/Theme.DeviceDefault.Light.NoActionBar"> <item name="android:windowAnimationStyle">@null</item> + <item name="android:windowLightStatusBar">true</item> + <item name="android:windowLightNavigationBar">true</item> + <item name="android:statusBarColor">@android:color/white</item> + <item name="android:navigationBarColor">@android:color/white</item> </style> <!-- diff --git a/play/CHECKLIST.md b/play/CHECKLIST.md new file mode 100644 index 0000000..8731276 --- /dev/null +++ b/play/CHECKLIST.md @@ -0,0 +1,161 @@ +# Publishing AssistKey on Google Play + +Everything that can be prepared without your Google account is already in this +repository. What is left needs your login, your card, or your identity, in this +order. + +## 1. Open the developer account (you, once) + +1. Go to <https://play.google.com/console/signup> with the Google account that + should own the app. +2. Choose **Personal** unless you have a registered business. +3. Pay the registration fee: **US$25, one time**. (Not $5.) +4. Complete identity verification. Google asks for a government ID and a phone + number, and the name must match the payment card. Approval takes from a few + hours to a few days. +5. To be paid, set up a **payments profile** (Play Console > Setup > Payments + profile) with a bank account and tax information. You cannot create a priced + product until this exists. + +A personal account created after November 2023 must run a **closed test with at +least 12 testers opted in for 14 continuous days** before Google will let the +app go to production. That is your beta; plan for it. + +## 2. Create the app + +Play Console > **Create app** + +| Field | Value | +|---|---| +| App name | `AssistKey: Key Remapper` | +| Default language | English (United States) | +| App or game | App | +| Free or paid | **Free** (the licence is an in-app product; a paid app could not give testers a discount) | + +The package name is fixed by the first upload: `dev.equwal.assistkey`. + +## 3. App signing - do this before the first upload + +Play re-signs every app. If it signs with a key of its own, the APK you hand to +testers and the copy Play delivers will carry different signatures. Then Play +cannot upgrade a sideloaded install in place, and purchases made from a +sideloaded install fail. + +So give Play **the key this repo already signs with**: + +Play Console > Test and release > Setup > **App signing** > *Use a different +key* > *Export and upload a key from Java keystore*. Download the +`encryption_public_key.pem` it offers and the PEPK tool, then: + +```bash +java -jar pepk.jar --keystore=assistkey-release.jks --alias=assistkey \ + --output=assistkey-play-signing.zip --include-cert \ + --rsa-aes-encryption --encryption-key-path=encryption_public_key.pem +``` + +It asks for the keystore password, which is in `keystore.properties`. Upload the +zip. Expected certificate SHA-256: + +``` +bcb3127e1931a9b906e8e2498184ef5380f97cbf3531e5bc8bb0064476ee2919 +``` + +**Back up `assistkey-release.jks` and `keystore.properties` somewhere safe.** +Neither is in git. Without them you cannot ship another update. + +## 4. Upload the build + +Test and release > Testing > **Internal testing** > Create new release > upload +`AssistKey-0.0.1-alpha.aab` (attached to the GitHub release `v0.0.1-alpha`, or +rebuild with `./gradlew bundleRelease`). + +Internal testing needs no review and is live in minutes. Products cannot be +created until one build has been uploaded. + +## 5. Create the three products + +Monetize with Play > Products > **One-time products**. The ids can never be +changed or reused, and the app looks for exactly these: + +| Product id | Name | Price | State | +|---|---|---|---| +| `assistkey_pro` | AssistKey licence | **US$2.99** | Active | +| `assistkey_pro_tester` | AssistKey licence - beta tester price | **US$1.49** | Active | +| `assistkey_beta_open` | Beta access flag (not for sale) | US$0.99 | **Active while the beta runs** | + +All three: purchase type **Buy**, non-consumable. Let Play convert the prices +to other currencies. + +`assistkey_beta_open` is never offered by the app. It is only a switch - see +section 9. + +Pricing: Button Mapper Pro, the nearest comparable app, is US$2.99. AssistKey +serves a far smaller audience but does more on the hardware it targets, so it +matches that price rather than undercutting it. Prices are read from Play at +run time; changing one is a Console edit, not a release. + +## 6. Store listing + +Grow users > Store presence > **Main store listing**. Copy is in +[LISTING.md](LISTING.md); graphics are in [graphics/](graphics/). + +## 7. App content declarations + +Policy and programs > **App content**. Answers are in +[DECLARATIONS.md](DECLARATIONS.md). The privacy policy is +[PRIVACY.md](PRIVACY.md) and must be reachable at a public URL - this repo is +private, so it has to be hosted elsewhere (a public gist, or a page on +equwal.com). + +The **accessibility declaration** is the one most likely to draw a reviewer. +The answer and the evidence for it are in DECLARATIONS.md. + +## 8. Test the purchase before anyone else does + +Billing could not be tested before the app existed on Play. Do this first: + +1. Play Console > Setup > **License testing**: add your own Gmail address. + License testers buy with a test card and are never charged. +2. Internal testing > Testers: add the same address, open the opt-in link on + the reader, install from Play. +3. On the reader, Google Play must be switched on - the Viwoods firmware ships + with it disabled. +4. Open AssistKey > the licence row. Prices should appear within a second or + two. Buy with the test card and confirm it shows **Unlocked**. +5. Refund it from Order management to test again. + +If the prices never appear, the usual causes are: products not Active, the +account not on the tester list, or the signatures not matching (section 3). + +## 9. Running the beta, and ending it + +**During the beta** hand testers either the Play opt-in link or +`AssistKey-0.0.1-alpha.apk` directly. Both are the same build. While +`assistkey_beta_open` is Active, every install is fully unlocked for free, and +each one quietly marks itself as a tester. + +**To end the beta:** set `assistkey_beta_open` to **Inactive**. Within hours, +every install that can reach Play drops to a 7-day trial and then locks. +Installs that cannot reach Play lock on the date compiled into the build, +`2027-03-31` (`assistkey.betaExpires` in `gradle.properties`). + +**The tester discount** then appears by itself: an install that ran during the +beta is offered `assistkey_pro_tester` instead of `assistkey_pro`. A tester who +changed device, or had to reinstall, types the tester code instead. The code is +in `keystore.properties` (`testerCode`), never in git; only its hash ships. +Changing it means a new release. + +To stop offering the discount later, deactivate `assistkey_pro_tester`. + +## 10. Go to production + +After the 14-day closed test, Play Console offers **Apply for production +access**. It asks about the test; answer plainly. Then promote the build to +Production and choose countries. + +## Releasing an update + +1. Raise `assistkey.versionCode` (must increase every upload) and + `assistkey.versionName` in `gradle.properties`. +2. `./gradlew assembleRelease bundleRelease` +3. Tag it, upload the `.aab`, attach both files to a GitHub release. diff --git a/play/DECLARATIONS.md b/play/DECLARATIONS.md new file mode 100644 index 0000000..cff03ba --- /dev/null +++ b/play/DECLARATIONS.md @@ -0,0 +1,90 @@ +# App content declarations + +Answers for Play Console > Policy and programs > App content. Each is true of +the build tagged `v0.0.1-alpha`; re-check them if the app changes. + +## Privacy policy + +Required, because the app uses the accessibility API. Host +[PRIVACY.md](PRIVACY.md) at a public URL and paste that URL. + +## Ads + +**No**, the app does not contain ads. + +## App access + +**All functionality is available without special access.** No login. + +## Content rating + +Category: **Utility, Productivity, Communication, or other**. Answer **No** to +every question about violence, sexuality, language, controlled substances, +gambling, user interaction and sharing location. Expected result: Everyone / +PEGI 3. + +## Target audience + +**18 and over.** The app is not designed for children. Choosing adult ages only +keeps it out of the Families policy, which it has no reason to be under. + +## News app / Health / Financial features / Government + +**No** to all. (The "wallet" channel makes AssistKey selectable as the wallet +app so that a button press reaches it. It holds no cards and moves no money, +so it is not a financial feature.) + +## Data safety + +| Question | Answer | +|---|---| +| Does your app collect or share any of the required user data types? | **No** | +| Is all of the user data collected by your app encrypted in transit? | Not applicable - nothing is collected | +| Do you provide a way for users to request that their data is deleted? | Not applicable | + +Why "No" is accurate: the app holds no `INTERNET` permission +(`aapt2 dump badging` on the release shows only `WRITE_SECURE_SETTINGS`, +`QUICK_ACCESS_WALLET` and `com.android.vending.BILLING`). Data that never +leaves the device is not "collected" in Play's sense. Purchase handling by +Google Play's own billing system does not have to be declared by the app. + +## Accessibility API declaration + +Play asks this of any app whose manifest declares an accessibility service. + +**Is your app an accessibility tool (built to support people with +disabilities)?** No. The manifest does not set `isAccessibilityTool`. + +**What core functionality uses the AccessibilityService API?** + +``` +AssistKey is a hardware key remapper for the Viwoods AiPaper Reader, an e-ink reading device. Remapping hardware keys is the app's only function, and the AccessibilityService API is the only public Android API that can do it. + +The service is used for two things: + +1. Key event filtering (flagRequestFilterKeyEvents / onKeyEvent). The service receives presses of the device's AI key and volume keys, recognises the gesture the user configured - single or multiple taps, press-and-hold, or a combination of keys - and consumes the press so that the user's chosen action runs instead of the default one. + +2. Performing the action the user bound to that gesture: performGlobalAction (Back, Home, Recents, notifications, quick settings, lock screen, screenshot), dispatchGesture (a swipe, used to turn pages in reading apps that accept only touch input), and ACTION_SCROLL_FORWARD / ACTION_SCROLL_BACKWARD on the scrollable node of the active window. + +Window content is retrieved only in case 2, only to locate a scrollable node, and only at the moment the user presses a key bound to the Scroll action. No window content, text, or key event is stored, logged or transmitted. The app does not request the INTERNET permission and cannot transmit anything. + +Before the user is sent to the accessibility settings, the app shows a prominent in-app disclosure describing exactly this use, and proceeds only if the user taps Agree. +``` + +**Disclosure evidence.** If the form asks for a video: on the main screen tick +*Accessibility key filter*. The disclosure dialog appears before anything else, +and only *Agree* continues to Android's settings. Record that on any phone +pointed at the reader - the reader cannot capture its own screen. + +## Permissions a reviewer may ask about + +| Permission | Why | +|---|---| +| `BIND_ACCESSIBILITY_SERVICE` | Above. | +| `WRITE_SECURE_SETTINGS` | Cannot be granted to a Play install; it does nothing unless the owner grants it over adb. It then lets the app switch the firmware's power-button behaviour (short press, hold duration). Declared so that the grant is possible at all. | +| `QUICK_ACCESS_WALLET` | Required of any app offered as the wallet app. AssistKey serves an empty card list; it exists so a press of the wallet shortcut reaches the user's chosen action. | +| `com.android.vending.BILLING` | Play Billing. | + +The assistant, camera and wallet entry points are disabled in the manifest and +are enabled one by one only when the user ticks the matching channel, so an +untouched install never appears as a candidate for any of those roles. diff --git a/play/LISTING.md b/play/LISTING.md new file mode 100644 index 0000000..163b76f --- /dev/null +++ b/play/LISTING.md @@ -0,0 +1,94 @@ +# Store listing copy + +Paste into Play Console > Store presence > Main store listing. + +## App name (30 max) + +``` +AssistKey: Key Remapper +``` + +## Short description (80 max) + +``` +Remap the AI key, volume keys and power button on your Viwoods AiPaper. +``` + +## Full description (4000 max) + +``` +AssistKey remaps the hardware keys on the Viwoods AiPaper Reader: the AI key, both volume keys, and the power button. + +Turn pages with the volume keys. Make the AI key go Back, or Home, or open your reading app. Put three different actions on one key with a tap, a double tap and a hold. Hold the power button to launch anything you like. + +WHAT EACH KEY CAN DO + +AI key and volume keys +• 1 to 5 taps, each bound separately +• Press and hold +• Combinations: hold one key and press another +• A key with only a single-tap binding fires instantly - you only wait out a double-tap window on keys where you asked for a double tap + +Power button +• Press and hold: run any action (AssistKey becomes your digital assistant app) +• Double press: run any action (AssistKey becomes your default camera app) +• Short press: choose the firmware behaviour + +ACTIONS + +• Viwoods AI: crop, lookup, quick prompt, full assistant, history, repository, edit screenshot +• Navigation: Back, Home, Recents, notifications, quick settings, power menu, lock screen, screenshot +• Page turning: swipes and scrolls, for reading apps that only accept touch +• Sound and media: volume up, down, mute, play/pause, next, previous +• Open any app, or any screen inside an app +• Send an intent or a broadcast, for automation apps +• Do nothing - disable a key you keep pressing by accident + +BUILT FOR E-INK + +Black on white, large text, no animation. No ads. No account. + +PRIVATE BY CONSTRUCTION + +AssistKey has no internet permission. It cannot send anything anywhere, and you can verify that on the app's permissions page. It collects nothing and stores nothing except your own key bindings, on your device. + +ACCESSIBILITY SERVICE + +AssistKey uses Android's AccessibilityService API, and only to remap hardware keys. The service receives key presses so it can recognise taps, holds and combinations, and performs the action you chose - Back, Home, a swipe - on your behalf. It looks at the window in front only to find a scrollable area when you use the Scroll action. It does not record what you type or what is on your screen. The app explains this and asks for your agreement before sending you to the accessibility switch. + +GOOD TO KNOW + +• Made for the Viwoods AiPaper Reader. The volume and power features work on most Android 12+ devices; the AI key and Viwoods actions are specific to Viwoods hardware. +• Android does not let any app see the power button directly, so power gestures are limited to short press, double press and hold. +• If a volume key does not respond, the device's own key settings may be holding on to it. The built-in key tester shows exactly which keys AssistKey can see, and the Firmware key hooks screen shows the fix. + +PRICE + +Free to install and try. A one-time purchase unlocks it permanently - no subscription. Your licence follows your Google account to every device you own. +``` + +## Category and tags + +| Field | Value | +|---|---| +| Category | Tools | +| Tags | Tools, Productivity | +| Contact email | truex@equwal.com | +| Website | (optional) | + +## Graphics + +| Asset | File | Spec | +|---|---|---| +| App icon | `graphics/icon-512.png` | 512 x 512, 32-bit PNG | +| Feature graphic | `graphics/feature-1024x500.png` | 1024 x 500 | +| Phone screenshots | `graphics/screenshot-*.png` | at least 2; taken on the reader | + +The reader's screen is greyscale, so are the screenshots. That is the product, +not a defect - do not colourise them. + +## Release notes for 0.0.1-alpha + +``` +First alpha. Remaps the AI key, volume keys and power button, with multi-tap, hold and key combinations. Everything is free while the beta runs. +``` diff --git a/play/PRIVACY.md b/play/PRIVACY.md new file mode 100644 index 0000000..dd856c7 --- /dev/null +++ b/play/PRIVACY.md @@ -0,0 +1,65 @@ +# AssistKey privacy policy + +*Effective 20 September 2026* + +AssistKey is published by equwal. Contact: truex@equwal.com + +## The short version + +AssistKey collects nothing, stores nothing about you, and sends nothing +anywhere. It does not hold Android's internet permission, so it is not able to. + +## What the app handles + +**Hardware key presses.** With its accessibility service switched on, AssistKey +is told when the AI key or a volume key is pressed or released. It uses this +only to recognise the taps, holds and combinations you have set up and to run +the action you chose. Key presses are handled in memory as they arrive and are +not recorded. The built-in key tester lists recent presses on screen while that +screen is open; the list is never saved and is discarded when you leave it. + +**The window in front.** When you use the Scroll action, and only then, the +service asks Android which part of the current window can scroll, so that it +can scroll it. It does not read, record or transmit what the window contains. + +**Your settings.** Your key bindings, timing preferences and which capture +channels are on are saved in the app's private storage on your device. They +leave the device only through Android's own backup, if you have it on. + +**Licence state.** The app saves, on your device, the date it was first +opened, whether it was used during the beta, and which AssistKey products +Google Play reports your account as owning. + +## The accessibility service + +AssistKey uses Android's AccessibilityService API for one purpose: remapping +hardware keys, and carrying out navigation actions such as Back, Home and +Recents on your behalf. It is not used to collect information, and the app asks +for your agreement, in the app, before directing you to switch the service on. +You can switch it off at any time in Android's accessibility settings. + +## Purchases + +Purchases are made through Google Play. AssistKey never sees your payment +details. It learns from Google Play only whether your account owns a licence. +Google's handling of the purchase is covered by Google's own privacy policy: +<https://policies.google.com/privacy>. + +AssistKey includes Google's Play Billing Library in order to do this. The part +of that library which reports usage statistics to Google has been removed from +AssistKey, and the app has no network access with which it could do so. + +## What is not in the app + +No advertising. No analytics. No crash reporting. No accounts. No third-party +SDKs other than Google's Play Billing Library. + +## Children + +AssistKey is a utility for a general audience and is not directed at children. +It collects no personal information from anyone. + +## Changes + +If this policy changes, the new version will be published at the same address +with a new effective date. diff --git a/play/graphics/Render.java b/play/graphics/Render.java new file mode 100644 index 0000000..afcf900 --- /dev/null +++ b/play/graphics/Render.java @@ -0,0 +1,80 @@ +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; + +/** + * Renders the Play Store icon and feature graphic from the same geometry as + * res/drawable/ic_launcher_foreground.xml, so the store and the launcher agree. + * + * java Render.java (run from this directory; JDK 11+) + */ +public class Render { + + static final Color INK = new Color(0x11, 0x11, 0x11); + + /** The launcher glyph, drawn in its own 108-unit viewport. */ + static void glyph(Graphics2D g, double x, double y, double size) { + Graphics2D c = (Graphics2D) g.create(); + c.translate(x, y); + c.scale(size / 108.0, size / 108.0); + c.setColor(INK); + + Area ring = new Area(new Ellipse2D.Double(30, 30, 48, 48)); + ring.subtract(new Area(new Ellipse2D.Double(38, 38, 32, 32))); + c.fill(ring); + c.fill(new Rectangle2D.Double(50, 46, 8, 16)); + c.fill(new Rectangle2D.Double(30, 50, 6, 8)); + c.fill(new Rectangle2D.Double(22, 50, 4, 8)); + c.fill(new Rectangle2D.Double(72, 50, 6, 8)); + c.fill(new Rectangle2D.Double(82, 50, 4, 8)); + c.dispose(); + } + + static Graphics2D canvas(BufferedImage img) { + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.setColor(Color.WHITE); + g.fillRect(0, 0, img.getWidth(), img.getHeight()); + return g; + } + + public static void main(String[] a) throws Exception { + // Icon: Play applies its own rounded mask, so this is full-bleed white + // with the glyph enlarged to sit inside the safe zone. + BufferedImage icon = new BufferedImage(512, 512, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = canvas(icon); + glyph(g, -96, -96, 704); + g.dispose(); + ImageIO.write(icon, "png", new File("icon-512.png")); + + // Feature graphic: e-ink plain. Glyph left, name and promise right. + BufferedImage f = new BufferedImage(1024, 500, BufferedImage.TYPE_INT_RGB); + g = canvas(f); + glyph(g, -40, -55, 610); + g.setColor(INK); + g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 92)); + g.drawString("AssistKey", 500, 232); + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 34)); + g.setColor(new Color(0x44, 0x44, 0x44)); + g.drawString("Remap every hardware key", 504, 296); + g.drawString("on your Viwoods AiPaper", 504, 342); + g.setColor(INK); + g.setStroke(new BasicStroke(3f)); + g.drawLine(504, 256, 584, 256); + g.dispose(); + ImageIO.write(f, "png", new File("feature-1024x500.png")); + + System.out.println("wrote icon-512.png and feature-1024x500.png"); + } +} diff --git a/play/graphics/feature-1024x500.png b/play/graphics/feature-1024x500.png new file mode 100644 index 0000000..ba991bb Binary files /dev/null and b/play/graphics/feature-1024x500.png differ diff --git a/play/graphics/icon-512.png b/play/graphics/icon-512.png new file mode 100644 index 0000000..e5d99b8 Binary files /dev/null and b/play/graphics/icon-512.png differ diff --git a/play/graphics/screenshot-1-main.png b/play/graphics/screenshot-1-main.png new file mode 100644 index 0000000..888f96d Binary files /dev/null and b/play/graphics/screenshot-1-main.png differ diff --git a/play/graphics/screenshot-2-triggerlist.png b/play/graphics/screenshot-2-triggerlist.png new file mode 100644 index 0000000..5fb0cd3 Binary files /dev/null and b/play/graphics/screenshot-2-triggerlist.png differ diff --git a/play/graphics/screenshot-3-actionpicker.png b/play/graphics/screenshot-3-actionpicker.png new file mode 100644 index 0000000..24ca0e7 Binary files /dev/null and b/play/graphics/screenshot-3-actionpicker.png differ diff --git a/play/graphics/screenshot-4-power.png b/play/graphics/screenshot-4-power.png new file mode 100644 index 0000000..c500fa0 Binary files /dev/null and b/play/graphics/screenshot-4-power.png differ diff --git a/play/graphics/screenshot-5-license.png b/play/graphics/screenshot-5-license.png new file mode 100644 index 0000000..e0c8f7c Binary files /dev/null and b/play/graphics/screenshot-5-license.png differ diff --git a/play/graphics/screenshot-6-viwoods.png b/play/graphics/screenshot-6-viwoods.png new file mode 100644 index 0000000..44f8f0a Binary files /dev/null and b/play/graphics/screenshot-6-viwoods.png differ