commit 1c4d7451a4fd224d4bda724c61060039b14d1ed2 truex <truex@equwal.com> 2026-09-20 16:19:31 -0700 Add Play Billing licensing, and make the firmware hooks screen read-only Licensing. Four tiers, tried in order: LICENSED (Play says the account owns assistkey_pro or assistkey_pro_tester), BETA (free while the beta is open), TRIAL (seven days from first launch), LOCKED (the key filter and the three impersonation entry points go inert; bindings are kept). Whether the beta is open is answered by Google Play rather than a server of ours: a third product, assistkey_beta_open, is never sold and exists only as a flag. Deactivating it in Play Console closes the beta for every install at once. Only "paid product visible, flag missing" in the same response reads as closed, so an empty or failed answer can never lock anyone out; installs that cannot reach Play fall back to the date compiled into the build. Anyone who ran the app during the beta is marked as a tester and offered the cheaper product, with a code as the fallback for a new device. The billing library's telemetry runtime (datatransport) is excluded. It was merging INTERNET and ACCESS_NETWORK_STATE into the manifest, which an accessibility service that sees key presses has no business holding. The library initialises that runtime inside a catch-all and carries on without it; confirmed in bytecode and on the device. The library's auto-reconnection is off too: with the Play Store disabled, which is how this reader ships, it held every answer back for seconds. The accessibility switch now sits behind an in-app disclosure and an explicit Agree, as Play requires of any non-assistive use of the API. Firmware hooks. Measured with sendevent on the kernel input nodes, which unlike `adb shell input` travels the path a real press does: - The AI key reaches the accessibility filter whatever CustomAiKey says. - A volume key reaches it only while its Custom*Key setting is unset. Any value, even the default token, makes the firmware keep the key to itself. - An ordinary app cannot write those settings at all. SettingsProvider throws "You cannot keep your settings in the secure settings", with or without WRITE_SECURE_SETTINGS. So the Viwoods channel could never have worked, and its "restore" button was writing the very tokens that hide a key. It is now a read-only screen that shows each hook, says when one is hiding a key, and gives the adb command. WRITE_SETTINGS is dropped from the manifest. Pass-through of the AI key opens whatever the firmware has configured instead of a hard-coded guess. Also: the key log records only while the tester screen is open, so nothing a keyboard types is ever held, even in memory. Version is now 0.0.1-alpha, read from gradle.properties.
app/build.gradle.kts | 59 +++- app/src/main/AndroidManifest.xml | 5 +- .../java/dev/equwal/assistkey/channel/Channel.kt | 21 +- .../dev/equwal/assistkey/channel/ChannelEntry.kt | 11 + .../java/dev/equwal/assistkey/channel/Channels.kt | 7 - .../equwal/assistkey/engine/KeyFilterService.kt | 13 +- .../java/dev/equwal/assistkey/engine/KeyLog.kt | 10 + .../java/dev/equwal/assistkey/license/License.kt | 168 ++++++++++++ .../dev/equwal/assistkey/license/PlayBilling.kt | 304 +++++++++++++++++++++ .../main/java/dev/equwal/assistkey/license/Sku.kt | 20 ++ .../dev/equwal/assistkey/native/ViwoodsBridge.kt | 126 ++++----- .../dev/equwal/assistkey/ui/KeyTesterActivity.kt | 13 +- .../dev/equwal/assistkey/ui/LicenseActivity.kt | 198 ++++++++++++++ .../java/dev/equwal/assistkey/ui/MainActivity.kt | 76 +++++- .../dev/equwal/assistkey/ui/TriggerListActivity.kt | 9 + .../dev/equwal/assistkey/ui/ViwoodsActivity.kt | 118 ++------ gradle.properties | 11 +- 17 files changed, 960 insertions(+), 209 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 093d352..369b278 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.security.MessageDigest +import java.time.LocalDate +import java.time.ZoneOffset import java.util.Properties plugins { @@ -16,6 +19,25 @@ val keystoreProps = Properties().apply { } val hasSigning = keystoreProps.getProperty("storeFile") != null +fun prop(name: String): String = + (project.findProperty(name) as String?) ?: error("missing gradle property $name") + +/** Midnight UTC at the start of the given ISO date, as epoch millis. */ +fun epochMillis(isoDate: String): Long = + LocalDate.parse(isoDate) + .atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + +fun sha256Hex(text: String): String = + MessageDigest.getInstance("SHA-256") + .digest(text.toByteArray(Charsets.UTF_8)) + .joinToString("") { b -> "%02x".format(b) } + +// Only the digest of the tester code is compiled in. With no code configured +// the digest is of the empty string, which no entered code can match. +val testerCodeHash = sha256Hex( + (keystoreProps.getProperty("testerCode") ?: "").trim().uppercase() +) + android { namespace = "dev.equwal.assistkey" compileSdk = 36 @@ -25,8 +47,15 @@ android { // QuickAccessWalletService, which the wallet channel needs, is API 31. minSdk = 31 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = prop("assistkey.versionCode").toInt() + versionName = prop("assistkey.versionName") + + buildConfigField( + "long", "BETA_EXPIRES_MS", + epochMillis(prop("assistkey.betaExpires")).toString() + "L" + ) + buildConfigField("String", "BETA_EXPIRES_DATE", "\"" + prop("assistkey.betaExpires") + "\"") + buildConfigField("String", "TESTER_CODE_SHA256", "\"" + testerCodeHash + "\"") } signingConfigs { @@ -54,6 +83,10 @@ android { } } + buildFeatures { + buildConfig = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -70,6 +103,22 @@ android { } } -// Framework APIs only, so the APK stays a couple of hundred KB and there is -// nothing to audit but our own code. -dependencies { } +// One dependency, and only because there is no other way to sell on Play. +// Everything else is framework API. +// +// The billing library talks to the Play Store app over IPC and needs no network +// access of its own. What does want the network is its usage telemetry, which +// rides on Google's datatransport runtime and would merge INTERNET and +// ACCESS_NETWORK_STATE into the manifest. An accessibility service that sees +// key presses should not also hold INTERNET, so that runtime is left out. +// +// This is safe by construction, not by luck: the only class in the library that +// touches datatransport (zzdn in 8.3.0) initialises it inside a catch-all and +// falls back to "Skipping logging since initialization failed". Re-check that +// with javap before bumping the billing version, and re-check the merged +// permissions with `aapt2 dump badging` after. +dependencies { + implementation("com.android.billingclient:billing:8.3.0") { + exclude(group = "com.google.android.datatransport") + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd1cb4f..c189e06 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,6 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - <!-- User-grantable from a settings screen; drives the Viwoods channel. --> - <uses-permission android:name="android.permission.WRITE_SETTINGS" - tools:ignore="ProtectedPermissions" /> - <!-- Not grantable at install. Declared so that `adb shell pm grant` can turn it on for the power-gesture switches; without it those rows report the @@ -60,6 +56,7 @@ <activity android:name=".ui.ViwoodsActivity" android:exported="false" /> <activity android:name=".ui.TimingActivity" android:exported="false" /> <activity android:name=".ui.KeyTesterActivity" android:exported="false" /> + <activity android:name=".ui.LicenseActivity" 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 63ff709..545f449 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt @@ -27,7 +27,7 @@ enum class Channel( "accessibility", "Accessibility key filter", "Captures the AI key and both volume keys. Required for multi-tap, " + - "holds, chords, and for Back / Recents / swipe actions.", + "holds, combinations, and for Back / Recents / swipe actions.", listOf() ), @@ -41,28 +41,21 @@ enum class Channel( CAMERA( "camera", "Camera app", - "Captures double-press Power when the gesture target is Camera. " + - "Needs this app set as the default 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.", listOf("dev.equwal.assistkey.channel.CameraShimActivity") ), WALLET( "wallet", "Wallet app", - "Captures double-press Power when the gesture target is Wallet, plus " + - "the lock-screen wallet button and the quick-settings wallet tile.", + "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.", listOf( "dev.equwal.assistkey.channel.WalletActivity", "dev.equwal.assistkey.channel.WalletService" ) - ), - - VIWOODS( - "viwoods", - "Viwoods native key hooks", - "Rebinds the firmware key settings directly. Works without the " + - "accessibility service, but single-action only - no multi-tap.", - listOf() ); /** @@ -74,7 +67,7 @@ enum class Channel( get() = when (this) { ASSISTANT -> POWER_HOLD CAMERA, WALLET -> POWER_DOUBLE - ACCESSIBILITY, VIWOODS -> null + ACCESSIBILITY -> null } companion object { 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 37e9101..1a66b65 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.widget.Toast +import dev.equwal.assistkey.license.License import dev.equwal.assistkey.route.ActionRouter import dev.equwal.assistkey.route.ServiceHolder import dev.equwal.assistkey.store.Store @@ -24,6 +25,16 @@ object ChannelEntry { * activity launch restrictions would drop it. */ fun handle(activity: Activity, channel: Channel) { + if (!License.active(activity)) { + Toast.makeText( + activity, + "AssistKey is locked - open the app to unlock it", + Toast.LENGTH_SHORT + ).show() + activity.finishAndVanish() + return + } + val trigger = channel.trigger val spec = trigger?.let { Store.bindings(activity)[it] } 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 1ca57e2..310eec4 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt @@ -75,7 +75,6 @@ object Channels { Channel.ASSISTANT -> isRoleHeld(c, RoleManager.ROLE_ASSISTANT) Channel.WALLET -> isRoleHeld(c, ROLE_WALLET) Channel.CAMERA -> isDefaultCamera(c) - Channel.VIWOODS -> Settings.System.canWrite(c) } /** One line for the UI, describing exactly what is missing. */ @@ -89,7 +88,6 @@ object Channels { if (isRoleAvailable(c, ROLE_WALLET)) "Not the default wallet app" else "No wallet role on this firmware" Channel.CAMERA -> "Not the default camera app" - Channel.VIWOODS -> "Needs permission to modify system settings" } } @@ -154,11 +152,6 @@ object Channels { ?: defaultApps() Channel.CAMERA -> defaultApps() - - Channel.VIWOODS -> Intent( - Settings.ACTION_MANAGE_WRITE_SETTINGS, - Uri.parse("package:" + c.packageName) - ) }.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) private fun roleRequest(c: Context, role: String): Intent? { 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 b305ed6..ea7316e 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -8,11 +8,13 @@ import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.license.License +import dev.equwal.assistkey.license.PlayBilling import dev.equwal.assistkey.model.ActionKind import dev.equwal.assistkey.model.ActionSpec import dev.equwal.assistkey.model.HwKey -import dev.equwal.assistkey.model.Presets import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.route.ActionRouter import dev.equwal.assistkey.route.ServiceHolder import dev.equwal.assistkey.store.Store @@ -34,6 +36,9 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { super.onServiceConnected() engine = GestureEngine(this, Store.timing(this)) ServiceHolder.service = this + // The service starts at boot and may run for weeks without the settings + // screen ever opening, so it keeps the licence fresh on its own. + PlayBilling.refresh(this) } override fun onUnbind(intent: android.content.Intent?): Boolean { @@ -52,6 +57,8 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { val key = HwKey.fromCode(event.keyCode) val consumed = when { !Channels.isEnabled(this, Channel.ACCESSIBILITY) -> false + // Locked means inert, not broken: every key goes to the firmware. + !License.active(this) -> false key == null || !key.interceptable -> false event.action == KeyEvent.ACTION_DOWN -> engine.onDown(key, event.eventTime, event.repeatCount) @@ -60,6 +67,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { } // Every event, not just the ones we act on: the tester screen is the // only way to find out whether a key reaches a filter on this firmware. + // KeyLog drops it on the floor unless that screen is open. KeyLog.record(event, key?.label ?: ("Key " + event.keyCode), consumed) return consumed } @@ -105,9 +113,10 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { when (keys.first()) { HwKey.VOL_UP -> adjust(AudioManager.ADJUST_RAISE) HwKey.VOL_DOWN -> adjust(AudioManager.ADJUST_LOWER) + // Whatever the firmware would have opened, not a guess at it. HwKey.AI -> ActionRouter.run( this, - ActionSpec(ActionKind.LAUNCH_COMPONENT, Presets.STOCK_AI_KEY) + ActionSpec(ActionKind.LAUNCH_COMPONENT, ViwoodsBridge.aiTarget(this)) ) HwKey.POWER -> Unit } diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt index 1014a58..bd8bf57 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt @@ -38,8 +38,18 @@ object KeyLog { private val entries = ArrayDeque<Entry>(CAPACITY) + /** + * Only the tester screen turns this on, and only while it is in front. A + * key filter is handed every key on the device, a Bluetooth keyboard's + * included, and the app's promise is that it keeps none of them - so + * outside that screen nothing is remembered, even in memory. + */ + @Volatile + var recording = false + @Synchronized fun record(event: KeyEvent, label: String, consumed: Boolean) { + if (!recording) return if (entries.size >= CAPACITY) entries.removeFirst() entries.addLast( Entry( diff --git a/app/src/main/java/dev/equwal/assistkey/license/License.kt b/app/src/main/java/dev/equwal/assistkey/license/License.kt new file mode 100644 index 0000000..0cee602 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/license/License.kt @@ -0,0 +1,168 @@ +package dev.equwal.assistkey.license + +import android.content.Context +import android.content.SharedPreferences +import dev.equwal.assistkey.BuildConfig +import java.security.MessageDigest + +/** + * Whether the app is allowed to do its job, and why. + * + * There are four tiers, tried in order: + * + * 1. LICENSED - Google Play says this account owns one of the two paid + * products. Cached, so it survives being offline. + * 2. BETA - the free beta is still open. Everything works. + * 3. TRIAL - the beta is over; seven days from first launch to decide. + * 4. LOCKED - nothing is remapped until a licence is bought. + * + * "Is the beta open" is answered by Google Play, not by a server of ours. The + * catalogue holds a third product, [Sku.BETA_OPEN], that is never sold; it is + * only a flag. While it is active the beta is open. Deactivating it in Play + * Console ends the beta for every install at once, sideloaded or not. The + * state "the paid product is visible but the flag is gone" is the only one read + * as closed, so an install that cannot reach Play - or a build that predates + * the Play listing entirely - stays open, up to the date compiled into it. + * + * Anyone who ran the app while the beta was open is remembered as a tester and + * is offered [Sku.PRO_TESTER], the same licence at a lower price. + */ +object License { + + enum class Tier { LICENSED, BETA, TRIAL, LOCKED } + + data class State( + val tier: Tier, + /** Whole days of trial left, rounded up; zero outside TRIAL. */ + val trialDaysLeft: Int = 0, + /** True when Play has positively confirmed the beta flag, not just a date. */ + val betaConfirmedByPlay: Boolean = false + ) { + val active: Boolean get() = tier != Tier.LOCKED + } + + /** What a catalogue lookup said about one product. */ + object Seen { + const val UNKNOWN = 0 + const val FOUND = 1 + const val NOT_FOUND = 2 + } + + private const val PREFS = "assistkey_license" + private const val K_FIRST_RUN = "first_run_at" + private const val K_TESTER = "beta_tester" + private const val K_CODE_OK = "tester_code_ok" + private const val K_OWNED = "owned" + private const val K_CAT_PRO = "catalog_pro" + private const val K_CAT_BETA = "catalog_beta" + private const val K_CAT_AT = "catalog_at" + + private const val DAY_MS = 24L * 60 * 60 * 1000 + const val TRIAL_DAYS = 7 + + /** + * A catalogue answer older than this is not trusted to say the beta is + * still open; the compiled-in date takes over again. + */ + private const val CATALOG_FRESH_MS = 14 * DAY_MS + + /** The key filter asks on every key event, so the answer is held briefly. */ + private const val STATE_TTL_MS = 30_000L + + @Volatile private var cached: State? = null + @Volatile private var cachedAt = 0L + + private fun prefs(c: Context): SharedPreferences = + c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + // ---- reading ------------------------------------------------------------ + + fun state(c: Context): State { + val now = System.currentTimeMillis() + cached?.let { if (now - cachedAt < STATE_TTL_MS) return it } + return compute(c, now).also { + cached = it + cachedAt = now + } + } + + fun active(c: Context): Boolean = state(c).active + + fun invalidate() { + cached = null + } + + private fun compute(c: Context, now: Long): State { + val p = prefs(c) + val firstRun = firstRunAt(p, now) + + val owned = p.getStringSet(K_OWNED, emptySet()) ?: emptySet() + if (owned.any { it in Sku.LICENCES }) return State(Tier.LICENSED) + + val fresh = now - p.getLong(K_CAT_AT, 0L) < CATALOG_FRESH_MS + val pro = if (fresh) p.getInt(K_CAT_PRO, Seen.UNKNOWN) else Seen.UNKNOWN + val beta = if (fresh) p.getInt(K_CAT_BETA, Seen.UNKNOWN) else Seen.UNKNOWN + + val open = when { + beta == Seen.FOUND -> true + pro == Seen.FOUND && beta == Seen.NOT_FOUND -> false + else -> now < BuildConfig.BETA_EXPIRES_MS + } + if (open) { + if (!p.getBoolean(K_TESTER, false)) p.edit().putBoolean(K_TESTER, true).apply() + return State(Tier.BETA, betaConfirmedByPlay = beta == Seen.FOUND) + } + + val left = firstRun + TRIAL_DAYS * DAY_MS - now + if (left > 0) { + return State(Tier.TRIAL, trialDaysLeft = ((left + DAY_MS - 1) / DAY_MS).toInt()) + } + return State(Tier.LOCKED) + } + + private fun firstRunAt(p: SharedPreferences, now: Long): Long { + val at = p.getLong(K_FIRST_RUN, 0L) + if (at in 1..now) return at + p.edit().putLong(K_FIRST_RUN, now).apply() + return now + } + + /** Ran the app during the beta, or typed the code a tester was given. */ + fun testerEligible(c: Context): Boolean { + val p = prefs(c) + return p.getBoolean(K_TESTER, false) || p.getBoolean(K_CODE_OK, false) + } + + // ---- writing, from PlayBilling ------------------------------------------ + + fun recordOwned(c: Context, productIds: Set<String>) { + prefs(c).edit().putStringSet(K_OWNED, HashSet(productIds)).apply() + invalidate() + } + + fun recordCatalog(c: Context, pro: Int, beta: Int) { + prefs(c).edit() + .putInt(K_CAT_PRO, pro) + .putInt(K_CAT_BETA, beta) + .putLong(K_CAT_AT, System.currentTimeMillis()) + .apply() + invalidate() + } + + // ---- tester code --------------------------------------------------------- + + /** + * For testers who cannot carry the marker over - a new device, or a Play + * install that refused to upgrade in place over the sideloaded beta. + */ + fun redeemTesterCode(c: Context, code: String): Boolean { + val digest = MessageDigest.getInstance("SHA-256") + .digest(code.trim().uppercase().toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + val ok = MessageDigest.isEqual( + digest.toByteArray(), BuildConfig.TESTER_CODE_SHA256.toByteArray() + ) + if (ok) prefs(c).edit().putBoolean(K_CODE_OK, true).apply() + return ok + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/license/PlayBilling.kt b/app/src/main/java/dev/equwal/assistkey/license/PlayBilling.kt new file mode 100644 index 0000000..bafa120 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/license/PlayBilling.kt @@ -0,0 +1,304 @@ +package dev.equwal.assistkey.license + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.UnfetchedProduct + +/** + * Everything that touches Google Play Billing. + * + * The rest of the app never sees a billing type: this object turns what Play + * says into [License.recordOwned] and [License.recordCatalog], and the licence + * is computed from those. That keeps the key-event hot path free of IPC, and + * means a device with no Play Store at all simply never writes anything. + * + * All state here is confined to the main thread. Billing callbacks are + * re-posted onto it rather than trusted to arrive there. + */ +object PlayBilling { + + private const val TAG = "AssistKey" + + enum class Availability { UNKNOWN, READY, UNAVAILABLE } + + /** Whether Play could be reached the last time anyone tried. */ + var availability = Availability.UNKNOWN + private set + + /** The last problem worth showing a person, or null if there is none. */ + var lastProblem: String? = null + private set + + /** True while a purchase is waiting on a slow payment method. */ + var purchasePending = false + private set + + /** Set by whichever screen is in front, so it can redraw. */ + var onChange: (() -> Unit)? = null + + private val main = Handler(Looper.getMainLooper()) + private val details = HashMap<String, ProductDetails>() + private val waiters = ArrayList<() -> Unit>() + private var client: BillingClient? = null + private var inFlight = false + + // ---- what the screens read ---------------------------------------------- + + fun price(productId: String): String? = details[productId]?.let(::offerOf)?.formattedPrice + + fun canBuy(productId: String): Boolean = details.containsKey(productId) + + // ---- refresh -------------------------------------------------------------- + + /** + * Connects if need be, then re-reads the catalogue and the purchases. + * Callers that arrive while one is running share its result. + */ + fun refresh(c: Context, done: (() -> Unit)? = null) { + val app = c.applicationContext + main.post { + done?.let { waiters.add(it) } + if (inFlight) return@post + inFlight = true + connect(app) { ok -> + if (!ok) finish() else queryCatalog(app) { queryOwned(app) { finish() } } + } + } + } + + private fun finish() { + inFlight = false + val run = ArrayList(waiters) + waiters.clear() + run.forEach { it() } + onChange?.invoke() + } + + private fun connect(app: Context, then: (Boolean) -> Unit) { + val existing = client + if (existing != null && existing.isReady) { + then(true) + return + } + val fresh = existing ?: try { + BillingClient.newBuilder(app) + .setListener(purchaseListener(app)) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() + ) + .build() + .also { client = it } + } catch (e: Exception) { + // No Play Store, or one too old to speak this protocol. + Log.w(TAG, "billing client could not be built", e) + unavailable("Google Play billing is not available on this device.") + then(false) + return + } + + var answered = false + fresh.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + main.post { + if (answered) return@post + answered = true + Log.i(TAG, "billing setup: " + result.responseCode + " " + result.debugMessage) + if (result.responseCode == BillingResponseCode.OK) { + availability = Availability.READY + then(true) + } else { + unavailable(explain(result)) + then(false) + } + } + } + + override fun onBillingServiceDisconnected() { + // Nothing to do here. Every refresh checks isReady and connects + // again if need be, which is one attempt with a prompt answer. + // The library's own auto-reconnection was tried and dropped: on + // a device with the Play Store disabled - how this reader ships + // - it retries three times per call and holds each answer back + // for seconds. + } + }) + } + + private fun unavailable(why: String) { + availability = Availability.UNAVAILABLE + lastProblem = why + } + + private fun queryCatalog(app: Context, then: () -> Unit) { + val c = client ?: return then() + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + Sku.ALL.map { + QueryProductDetailsParams.Product.newBuilder() + .setProductId(it) + .setProductType(ProductType.INAPP) + .build() + } + ) + .build() + + c.queryProductDetailsAsync(params) { result, response -> + main.post { + if (result.responseCode == BillingResponseCode.OK) { + details.clear() + response.productDetailsList.forEach { details[it.productId] = it } + val missing = response.unfetchedProductList + .associate { it.productId to it.statusCode } + License.recordCatalog( + app, + pro = seen(Sku.PRO, missing), + beta = seen(Sku.BETA_OPEN, missing) + ) + lastProblem = null + } else { + // Keep whatever was cached; one bad answer proves nothing. + lastProblem = explain(result) + Log.i(TAG, "catalogue query: " + result.responseCode + " " + result.debugMessage) + } + then() + } + } + } + + /** + * Present means found. Absent means not found - unless Play said it could + * not tell, in which case neither can we. A product whose only purchase + * option has been switched off comes back as NO_ELIGIBLE_OFFER rather than + * PRODUCT_NOT_FOUND; for a flag that means the same thing. + */ + private fun seen(productId: String, missing: Map<String, Int>): Int = when { + details.containsKey(productId) -> License.Seen.FOUND + missing[productId] == UnfetchedProduct.StatusCode.UNKNOWN -> License.Seen.UNKNOWN + else -> License.Seen.NOT_FOUND + } + + private fun queryOwned(app: Context, then: () -> Unit) { + val c = client ?: return then() + val params = QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build() + c.queryPurchasesAsync(params) { result, purchases -> + main.post { + if (result.responseCode == BillingResponseCode.OK) { + absorb(app, purchases, replace = true) + } else { + Log.i(TAG, "purchase query: " + result.responseCode + " " + result.debugMessage) + } + then() + } + } + } + + // ---- buying --------------------------------------------------------------- + + /** Returns false, with [lastProblem] set, if the purchase sheet could not open. */ + fun buy(activity: Activity, productId: String): Boolean { + val product = details[productId] + val c = client + if (product == null || c == null || !c.isReady) { + lastProblem = "Google Play has not answered yet. Try again in a moment." + return false + } + val item = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product) + offerOf(product)?.offerToken?.takeIf { it.isNotEmpty() }?.let { item.setOfferToken(it) } + + val result = c.launchBillingFlow( + activity, + BillingFlowParams.newBuilder().setProductDetailsParamsList(listOf(item.build())).build() + ) + if (result.responseCode == BillingResponseCode.OK) return true + if (result.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED) { + refresh(activity) + return true + } + lastProblem = explain(result) + return false + } + + private fun purchaseListener(app: Context) = PurchasesUpdatedListener { result, purchases -> + main.post { + when (result.responseCode) { + BillingResponseCode.OK -> { + absorb(app, purchases ?: emptyList(), replace = false) + lastProblem = null + } + BillingResponseCode.USER_CANCELED -> Unit + BillingResponseCode.ITEM_ALREADY_OWNED -> refresh(app) + else -> lastProblem = explain(result) + } + onChange?.invoke() + } + } + + /** + * Records what is owned and acknowledges anything new. Acknowledging is not + * optional: Play refunds a purchase that goes three days without it. + */ + private fun absorb(app: Context, purchases: List<Purchase>, replace: Boolean) { + val owned = HashSet<String>() + var pending = false + purchases.forEach { p -> + when (p.purchaseState) { + Purchase.PurchaseState.PURCHASED -> { + owned.addAll(p.products) + if (!p.isAcknowledged) acknowledge(p) + } + Purchase.PurchaseState.PENDING -> pending = true + else -> Unit + } + } + purchasePending = pending + if (replace || owned.isNotEmpty()) License.recordOwned(app, owned) + } + + private fun acknowledge(p: Purchase) { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(p.purchaseToken).build() + client?.acknowledgePurchase(params) { result -> + if (result.responseCode != BillingResponseCode.OK) { + // Not fatal: the next refresh sees it unacknowledged and retries. + Log.w(TAG, "acknowledge failed: " + result.responseCode + " " + result.debugMessage) + } + } + } + + // ---- helpers ---------------------------------------------------------------- + + /** The cheapest way to buy a product, should it ever carry more than one offer. */ + private fun offerOf(p: ProductDetails): ProductDetails.OneTimePurchaseOfferDetails? = + p.oneTimePurchaseOfferDetailsList?.minByOrNull { it.priceAmountMicros } + ?: p.oneTimePurchaseOfferDetails + + private fun explain(r: BillingResult): String = when (r.responseCode) { + BillingResponseCode.BILLING_UNAVAILABLE -> + "Google Play billing is not available for this device or account." + BillingResponseCode.SERVICE_UNAVAILABLE, + BillingResponseCode.SERVICE_DISCONNECTED, + BillingResponseCode.NETWORK_ERROR -> + "Could not reach Google Play. Check the connection and try again." + BillingResponseCode.ITEM_UNAVAILABLE -> + "That product is not available for this account." + BillingResponseCode.FEATURE_NOT_SUPPORTED -> + "The Play Store app on this device is too old. Update it and try again." + else -> "Google Play error " + r.responseCode + + (if (r.debugMessage.isNullOrBlank()) "" else ": " + r.debugMessage) + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/license/Sku.kt b/app/src/main/java/dev/equwal/assistkey/license/Sku.kt new file mode 100644 index 0000000..db42760 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/license/Sku.kt @@ -0,0 +1,20 @@ +package dev.equwal.assistkey.license + +/** + * The three product ids, exactly as they must be typed into Play Console. + * Product ids can never be changed or reused once created, so neither can + * these. + */ +object Sku { + /** The licence. One-time, non-consumable. */ + const val PRO = "assistkey_pro" + + /** The same licence at the tester price. Only offered to [License.testerEligible]. */ + const val PRO_TESTER = "assistkey_pro_tester" + + /** Never sold. Active means the beta is open; deactivate it to end the beta. */ + const val BETA_OPEN = "assistkey_beta_open" + + val LICENCES = setOf(PRO, PRO_TESTER) + val ALL = listOf(PRO, PRO_TESTER, BETA_OPEN) +} 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 40dae98..ac8232d 100644 --- a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt +++ b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt @@ -7,19 +7,31 @@ import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Presets /** - * The firmware's own key remapper, which turns out to be nothing more than a - * handful of Settings.System strings that the Viwoods launcher reads. The names - * and the token vocabulary below were read out of setting_se08 1.3.7 on + * The firmware's own key remapper: a handful of Settings.System strings. The + * names and the token vocabulary were read out of setting_se08 1.3.7 on * firmware 1.5.6. * - * Writing them needs WRITE_SETTINGS, which the user can grant from an ordinary - * settings screen - no adb - so this is the one channel that works on a - * completely untouched device. The cost is one action per key: no multi-tap, - * no holds, no chords. + * This is read-only, and not by choice. An ordinary app cannot write these + * keys. SettingsProvider refuses any Settings.System name outside its public + * list unless the caller is a privileged system app - WRITE_SETTINGS does not + * help and neither does an adb-granted WRITE_SECURE_SETTINGS; the call dies + * with "You cannot keep your settings in the secure settings". Only the shell + * can change them, so the most this app can do is show them and hand over the + * command. * - * The two key families are not symmetric. The volume keys hold a token, with - * the component kept alongside in *AppShortcut; the AI key holds the flat - * component itself and has no shortcut slot. + * They still matter, because of one interaction measured on the device: + * + * While a volume key's hook holds ANY value - even its "default" token, + * volume_up or volume_down - the firmware handles that key before the input + * filter stage, and no app ever sees the press. Unset, the key reaches the + * accessibility filter normally. + * + * The AI key is not like that: it reaches the filter whatever its hook says, + * and the hook only decides what happens when nothing consumes the press. + * + * The two families are not symmetric. A volume key holds a token, with a + * component kept alongside in *AppShortcut; the AI key holds the flat component + * itself. */ object ViwoodsBridge { @@ -32,10 +44,10 @@ object ViwoodsBridge { data class Slot(val key: String, val shortcut: String) - const val TOKEN_APP = "shortcut_app" + private const val TOKEN_APP = "shortcut_app" - /** Every value the stock launcher understands for a volume key. */ - val volumeTokens = listOf( + /** Every value the stock settings screen writes for a volume key. */ + private val volumeTokens = mapOf( "volume_up" to "Volume up", "volume_down" to "Volume down", "screenshot" to "Screenshot", @@ -44,78 +56,42 @@ object ViwoodsBridge { fun keys(): List<HwKey> = listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN) - fun canWrite(c: Context): Boolean = Settings.System.canWrite(c) - - // ---- reads ------------------------------------------------------------- + /** Defensive: an OEM key that a later firmware hides must not take the app down. */ + private fun get(c: Context, name: String): String? = + runCatching { Settings.System.getString(c.contentResolver, name) } + .getOrNull()?.takeIf { it.isNotBlank() && it != "null" } fun read(c: Context, key: HwKey): String? = when (key) { - HwKey.AI -> Settings.System.getString(c.contentResolver, AI_KEY) - else -> VOLUME_SLOT[key]?.let { - Settings.System.getString(c.contentResolver, it.key) - } + HwKey.AI -> get(c, AI_KEY) + else -> VOLUME_SLOT[key]?.let { get(c, it.key) } } - fun readShortcut(c: Context, key: HwKey): String? = - VOLUME_SLOT[key]?.let { Settings.System.getString(c.contentResolver, it.shortcut) } - - /** Human description of whatever the firmware currently has bound. */ - fun describe(c: Context, key: HwKey): String { - val v = read(c, key) - if (v.isNullOrBlank()) return "Not set (firmware default)" - if (key == HwKey.AI) return shortName(c, v) - - val named = volumeTokens.firstOrNull { it.first == v } ?: return shortName(c, v) - if (v != TOKEN_APP) return named.second - val target = readShortcut(c, key) - return if (target.isNullOrBlank()) "Open an app (none chosen)" - else "Open " + shortName(c, target) - } - - // ---- writes ------------------------------------------------------------ + private fun readShortcut(c: Context, key: HwKey): String? = + VOLUME_SLOT[key]?.let { get(c, it.shortcut) } /** - * Returns false when WRITE_SETTINGS has not been granted, so the caller can - * send the user to the grant screen rather than silently doing nothing. + * True when the firmware is keeping this key to itself, so that bindings + * made for it in this app can never fire. */ - fun bindComponent(c: Context, key: HwKey, flatComponent: String): Boolean { - if (!canWrite(c)) return false - return runCatching { - if (key == HwKey.AI) { - Settings.System.putString(c.contentResolver, AI_KEY, flatComponent) - } else { - val slot = VOLUME_SLOT[key] ?: return false - Settings.System.putString(c.contentResolver, slot.shortcut, flatComponent) - Settings.System.putString(c.contentResolver, slot.key, TOKEN_APP) - } - true - }.getOrDefault(false) - } + fun hidesFromFilter(c: Context, key: HwKey): Boolean = + VOLUME_SLOT.containsKey(key) && read(c, key) != null - fun bindToken(c: Context, key: HwKey, token: String): Boolean { - val slot = VOLUME_SLOT[key] ?: return false - if (!canWrite(c)) return false - return runCatching { - Settings.System.putString(c.contentResolver, slot.key, token); true - }.getOrDefault(false) - } + /** What the firmware does with the AI key when nothing consumes the press. */ + fun aiTarget(c: Context): String = read(c, HwKey.AI) ?: Presets.STOCK_AI_KEY - /** - * Points a firmware key at our own assist entry, which is how the Viwoods - * channel gets the full gesture engine on a device where the accessibility - * service is unavailable or unwanted. - */ - fun bindToUs(c: Context, key: HwKey): Boolean = - bindComponent(c, key, c.packageName + "/dev.equwal.assistkey.channel.AssistActivity") + /** The command that hands a volume key back to the input pipeline. */ + fun unsetCommand(key: HwKey): String? = + VOLUME_SLOT[key]?.let { "adb shell settings delete system " + it.key } - /** - * Hands a key back to the firmware: the AI key to its factory target, the - * volume keys to plain volume. - */ - fun restore(c: Context, key: HwKey): Boolean = when (key) { - HwKey.AI -> bindComponent(c, key, Presets.STOCK_AI_KEY) - HwKey.VOL_UP -> bindToken(c, key, "volume_up") - HwKey.VOL_DOWN -> bindToken(c, key, "volume_down") - HwKey.POWER -> false + /** 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 shortName(c, v) + + val label = volumeTokens[v] ?: return shortName(c, v) + if (v != TOKEN_APP) return label + val target = readShortcut(c, key) + return if (target == null) "Open an app (none chosen)" else "Open " + shortName(c, target) } private fun shortName(c: Context, flat: String): String { 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 ba41d25..fb5155a 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt @@ -41,12 +41,14 @@ class KeyTesterActivity : Activity() { override fun onResume() { super.onResume() + KeyLog.recording = true build() handler.postDelayed(tick, 400L) } override fun onPause() { super.onPause() + KeyLog.recording = false handler.removeCallbacks(tick) } @@ -69,8 +71,9 @@ class KeyTesterActivity : Activity() { "window manager consumes it before any app can see it." ) col.note( - "Only real presses count. Events injected over adb bypass " + - "accessibility input filters and will not show up." + "Only real presses count. Events injected with adb shell input " + + "bypass accessibility filters and will not show up. Keys are " + + "listed only while this screen is open, and never stored." ) col.button("Clear") { KeyLog.clear(); build() } @@ -79,9 +82,9 @@ class KeyTesterActivity : Activity() { col.header(if (recent.isEmpty()) "Nothing yet" else "Most recent first") if (recent.isEmpty()) { col.note( - "If a key never appears after pressing it, that key cannot be " + - "remapped through the accessibility channel on this " + - "firmware. Try the Viwoods native hooks instead." + "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." ) return } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt new file mode 100644 index 0000000..31c1da0 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt @@ -0,0 +1,198 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.widget.LinearLayout +import android.widget.Toast +import dev.equwal.assistkey.BuildConfig +import dev.equwal.assistkey.license.License +import dev.equwal.assistkey.license.PlayBilling +import dev.equwal.assistkey.license.Sku +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * Where the licence is shown, bought and restored. + * + * Prices are never written into the app. They come from Google Play at the + * moment the screen opens, already converted and formatted for the buyer's + * country, so changing a price is a Play Console edit and not a release. + */ +class LicenseActivity : Activity() { + + override fun onResume() { + super.onResume() + PlayBilling.onChange = { build() } + build() + PlayBilling.refresh(this) + } + + override fun onPause() { + super.onPause() + PlayBilling.onChange = null + } + + private fun build() { + val state = License.state(this) + val col = Ui.page(this) + col.title("Licence") + status(col, state) + if (state.tier != License.Tier.LICENSED) buy(col, state) + restore(col) + col.note("Version " + BuildConfig.VERSION_NAME) + } + + // ---- status --------------------------------------------------------------- + + 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.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( + 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." + } + ) + } + 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." + ) + } + 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." + ) + } + } + } + + private fun days(n: Int): String = if (n == 1) "1 day" else n.toString() + " days" + + // ---- buying --------------------------------------------------------------- + + private fun buy(col: LinearLayout, state: License.State) { + 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." + ) + } + + val tester = License.testerEligible(this) + val fullPrice = PlayBilling.price(Sku.PRO) + val testerPrice = PlayBilling.price(Sku.PRO_TESTER) + + when { + fullPrice == null && testerPrice == null -> notOnSale(col, state) + 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 "") + "." + ) + col.button("Buy at the tester price - " + testerPrice) { + purchase(Sku.PRO_TESTER) + } + } else if (fullPrice != null) { + col.button("Buy AssistKey - " + fullPrice) { purchase(Sku.PRO) } + } + col.note("One payment through Google Play. No subscription, no account.") + } + } + + if (!tester) { + col.row( + "I was a beta tester", + "Enter the code you were given to get the tester price" + ) { askForCode() } + } + } + + /** No prices means Play gave us nothing to sell - say which kind of nothing. */ + private fun notOnSale(col: LinearLayout, state: License.State) { + val problem = PlayBilling.lastProblem + col.note( + when { + 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." + 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." + } + ) + } + + private fun purchase(productId: String) { + if (!PlayBilling.buy(this, productId)) { + Toast.makeText( + this, + PlayBilling.lastProblem ?: "Could not start the purchase", + Toast.LENGTH_LONG + ).show() + } + } + + private fun askForCode() { + Ui.textInput(this, "Tester code", "TESTER-XXXX-XXXX") { code -> + val ok = License.redeemTesterCode(this, code) + Toast.makeText( + this, + if (ok) "Tester price unlocked" else "That code was not recognised", + Toast.LENGTH_LONG + ).show() + if (ok) build() + } + } + + // ---- restoring ------------------------------------------------------------- + + 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") { + Toast.makeText(this, "Checking...", Toast.LENGTH_SHORT).show() + PlayBilling.refresh(this) { + val tier = License.state(this).tier + Toast.makeText( + this, + if (tier == License.Tier.LICENSED) "Unlocked" else "No purchase found on this account", + Toast.LENGTH_LONG + ).show() + } + } + } +} 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 a38a7ba..4ef74bb 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -1,16 +1,20 @@ package dev.equwal.assistkey.ui import android.app.Activity +import android.app.AlertDialog import android.content.Intent import android.os.Bundle import android.widget.LinearLayout import android.widget.Toast import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.license.License +import dev.equwal.assistkey.license.PlayBilling import dev.equwal.assistkey.model.GestureType import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.PowerNative +import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.check @@ -36,6 +40,11 @@ class MainActivity : Activity() { override fun onResume() { super.onResume() build() + // Play answers asynchronously; redraw only if the answer changed the tier. + val before = License.state(this).tier + PlayBilling.refresh(this) { + if (License.state(this).tier != before && !isFinishing) build() + } } private fun build() { @@ -45,11 +54,29 @@ class MainActivity : Activity() { "Remaps the AI key, both volume keys and the power button. " + "Turn on the capture channels you want, then bind gestures below." ) + licence(col) channels(col) buttons(col) extras(col) } + // ---- licence ----------------------------------------------------------- + + private fun licence(col: LinearLayout) { + val s = License.state(this) + val (title, subtitle) = when (s.tier) { + License.Tier.LICENSED -> "Unlocked" to "Thank you for buying AssistKey" + License.Tier.BETA -> + "Beta - free for now" to "Everything works while the beta is open" + License.Tier.TRIAL -> + ("Trial - " + s.trialDaysLeft + (if (s.trialDaysLeft == 1) " day" else " days") + + " left") to "Tap to buy a licence" + License.Tier.LOCKED -> + "Locked - remapping is off" to "Tap to unlock. Your bindings are kept." + } + col.row(title, subtitle) { startActivity(Intent(this, LicenseActivity::class.java)) } + } + // ---- channels ---------------------------------------------------------- private fun channels(col: LinearLayout) { @@ -93,7 +120,37 @@ class MainActivity : Activity() { } } + /** + * Google Play requires that an app using the accessibility API for anything + * other than assistive technology says so, in the app, in plain words, and + * gets a yes before sending anyone to the switch. It is also simply the + * right thing to do with a permission this broad. + */ private fun claim(ch: Channel) { + if (ch != Channel.ACCESSIBILITY) return claimNow(ch) + AlertDialog.Builder(this) + .setTitle("Accessibility service") + .setMessage( + "AssistKey uses Android's AccessibilityService API for one purpose: " + + "remapping this device's hardware keys.\n\n" + + "With the service switched on, AssistKey:\n\n" + + "- receives presses of the AI key and the volume keys, so that it " + + "can recognise taps, holds and combinations;\n\n" + + "- performs the action you chose - Back, Home, Recents, a swipe, " + + "a scroll - on your behalf;\n\n" + + "- looks at the window in front only to find its scrollable area, " + + "and only when you use the Scroll action.\n\n" + + "It does not record what you type or what is on your screen. It " + + "collects nothing, stores nothing and sends nothing: the app has no " + + "internet permission.\n\n" + + "Agree to continue to Android's accessibility settings." + ) + .setPositiveButton("Agree") { _, _ -> claimNow(ch) } + .setNegativeButton("Not now", null) + .show() + } + + private fun claimNow(ch: Channel) { if (ch == Channel.CAMERA) { Toast.makeText( this, @@ -113,7 +170,13 @@ class MainActivity : Activity() { col.header("Buttons") listOf(HwKey.AI, HwKey.VOL_UP, HwKey.VOL_DOWN).forEach { key -> - col.row(key.label, boundSummary(setOf(key))) { + val summary = + if (ViwoodsBridge.hidesFromFilter(this, key)) { + "Hidden by a firmware hook - see Firmware key hooks below" + } else { + boundSummary(setOf(key)) + } + col.row(key.label, summary) { startActivity(TriggerListActivity.intent(this, setOf(key))) } } @@ -160,10 +223,12 @@ class MainActivity : Activity() { private fun extras(col: LinearLayout) { col.header("Advanced") + val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) } col.row( "Firmware key hooks", - if (Channels.isEnabled(this, Channel.VIWOODS)) "Viwoods channel settings" - else "Enable the Viwoods channel to use these" + if (hidden.isEmpty()) "What the firmware itself does with each key" + else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } + + " from this app - tap for the fix" ) { startActivity(Intent(this, ViwoodsActivity::class.java)) } col.row( @@ -181,9 +246,8 @@ class MainActivity : Activity() { col.header("If you get stuck") col.note( "Power + Volume up opens the power menu, whatever else is bound. " + - "Uninstalling the app restores every key to firmware defaults, " + - "except the ones written through the Viwoods channel - reset " + - "those from Firmware key hooks first." + "Uninstalling the app restores every key to its firmware " + + "behaviour; nothing it does outlives it." ) } } 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 a95e504..265ebaf 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt @@ -9,6 +9,7 @@ import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.model.GestureType import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger +import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note @@ -45,6 +46,14 @@ class TriggerListActivity : Activity() { ) } + 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." + ) + } + val b = Store.bindings(this) col.header("Taps") (1..Trigger.MAX_TAPS).forEach { n -> 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 b42c58e..ab15c51 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt @@ -1,26 +1,22 @@ package dev.equwal.assistkey.ui import android.app.Activity -import android.content.Intent import android.widget.LinearLayout -import android.widget.Toast -import dev.equwal.assistkey.channel.Channel -import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.model.HwKey -import dev.equwal.assistkey.model.Presets import dev.equwal.assistkey.native.ViwoodsBridge -import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.code import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row import dev.equwal.assistkey.ui.Ui.title /** - * The firmware's own key settings, edited directly. + * Shows what the firmware itself has bound to each key. * - * Worth having even though the accessibility channel is more capable: this one - * survives the accessibility service being killed, works on the lock screen, - * and costs nothing in latency. It just cannot do more than one action per key. + * Read-only: Android does not let an ordinary app write these settings, however + * many permissions it holds. The screen earns its place anyway, because a set + * volume hook is the one thing that makes a key silently unmappable, and + * nothing else on the device will tell you that is what happened. */ class ViwoodsActivity : Activity() { @@ -33,95 +29,37 @@ class ViwoodsActivity : Activity() { val col = Ui.page(this) col.title("Firmware key hooks") col.note( - "These are the same settings the stock key screen writes. Changing " + - "them here affects the whole system, not just this app, and they " + - "survive uninstalling it - reset them before you remove the app." + "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." ) - - if (!ViwoodsBridge.canWrite(this)) { - col.note("This needs permission to modify system settings.") - col.button("Grant permission") { - Channels.safeStart(this, Channels.claimIntent(this, Channel.VIWOODS)) - } - return - } - - ViwoodsBridge.keys().forEach { key -> keyBlock(col, key) } - - col.header("Reset") - col.button("Restore all three keys to factory") { - var ok = true - ViwoodsBridge.keys().forEach { if (!ViwoodsBridge.restore(this, it)) ok = false } - toast(if (ok) "Restored" else "Some keys could not be written") - build() - } + ViwoodsBridge.keys().forEach { keyBlock(col, it) } } private fun keyBlock(col: LinearLayout, key: HwKey) { col.header(key.label) - col.row("Currently: " + ViwoodsBridge.describe(this, key), null, enabled = false) - - col.row( - "Send to AssistKey", - "Routes this key into the gesture engine without the accessibility service" - ) { - toast(if (ViwoodsBridge.bindToUs(this, key)) "Bound" else "Write refused") - build() - } - - col.row("Viwoods AI target", "Pick one of the built-in AI screens") { - Ui.pick(this, key.label, Presets.viwoods.map { it.label }) { i -> - val p = Presets.viwoods[i] - toast( - if (ViwoodsBridge.bindComponent(this, key, p.component)) "Bound" - else "Write refused" - ) - build() - } - } + col.row("Firmware setting: " + ViwoodsBridge.describe(this, key), null, enabled = false) - if (key != HwKey.AI) { - col.row("Built-in behaviour", "Volume, screenshot, or open an app") { - Ui.pick(this, key.label, ViwoodsBridge.volumeTokens.map { it.second }) { i -> - val token = ViwoodsBridge.volumeTokens[i].first - if (token == ViwoodsBridge.TOKEN_APP) pickApp(key) else { - toast( - if (ViwoodsBridge.bindToken(this, key, token)) "Bound" - else "Write refused" - ) - build() - } - } - } - } - - col.row("Open an app", null) { pickApp(key) } - col.row("Restore factory value", null) { - toast(if (ViwoodsBridge.restore(this, key)) "Restored" else "Write refused") - build() + if (key == HwKey.AI) { + col.note( + "AssistKey sees the AI key whatever this says. The setting only " + + "decides what opens when a press is not bound to anything." + ) + return } - } - private fun pickApp(key: HwKey) { - val pm = packageManager - val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) - val apps = pm.queryIntentActivities(main, 0) - .map { - it.loadLabel(pm).toString() to - (it.activityInfo.packageName + "/" + it.activityInfo.name) - } - .sortedBy { it.first.lowercase() } - - if (apps.isEmpty()) { toast("No launchable apps visible"); return } - - Ui.pick(this, "Open an app", apps.map { it.first }) { i -> - toast( - if (ViwoodsBridge.bindComponent(this, key, apps[i].second)) "Bound" - else "Write refused" + 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:" ) - build() + 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.") } } - - private fun toast(s: String) = Toast.makeText(this, s, Toast.LENGTH_SHORT).show() } diff --git a/gradle.properties b/gradle.properties index 9d9fecd..dfb31f2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,15 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true -android.useAndroidX=false +android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official + +# Release identity. versionCode must rise with every upload to Play. +assistkey.versionName=0.0.1-alpha +assistkey.versionCode=1 + +# The free beta ends on this date (UTC) at the latest. It is the backstop for +# installs that cannot reach Google Play; the real switch is the +# assistkey_beta_open product in Play Console - see play/CHECKLIST.md. +assistkey.betaExpires=2027-03-31