Recently Written · git

assistkey

Hardware key remapper for the Viwoods AiPaper Reader: AI key, volume keys and power, with multi-tap, hold and chords

git clone https://github.com/equwal/assistkey

Log | Files | Refs


commit f89a23751e04f42320285d32c1d13de4d2fc34b6
truex <truex@equwal.com>
2026-09-21 02:31:21 -0700

Unlock the app on a device that has no Google Play

A device with no Google Play app, or with the app turned off, has no
way to buy a licence. The licence state is then NO_STORE and the app is
complete. A bought licence still comes first. The rules moved into
License.decide, which has no Android types, and LicenseDecideTest runs
them.

Record the decision of the owner in docs/MONETIZATION.md: no direct
licence for the full build, a tip link instead.

 CHANGELOG.md                                       |  9 ++++
 .../java/dev/equwal/assistkey/license/License.kt   | 41 ++++++++++++---
 .../dev/equwal/assistkey/ui/LicenseActivity.kt     | 11 +++-
 .../main/java/dev/equwal/assistkey/ui/Summary.kt   |  1 +
 .../equwal/assistkey/license/LicenseDecideTest.kt  | 61 ++++++++++++++++++++++
 .../java/dev/equwal/assistkey/ui/SummaryTest.kt    |  1 +
 docs/MONETIZATION.md                               |  8 +++
 7 files changed, 122 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cb650a..4fb765c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # Changelog
 
+## Unreleased
+
+- **No Google Play, no lock.** On a device where the Google Play app is not
+  installed or is turned off, the app is free and complete. There is no way
+  to buy there, so there is nothing to lock. The licence screen says "Free on
+  this device". A bought licence still comes first. Tests: `LicenseDecideTest`.
+- The reasons in the allow step are hints of three to seven words. The app
+  says "double tap" for Power too.
+
 ## 0.0.9-alpha - 2026-09-21
 
 Version code 9.
diff --git a/app/src/main/java/dev/equwal/assistkey/license/License.kt b/app/src/main/java/dev/equwal/assistkey/license/License.kt
index 0cee602..adcf04b 100644
--- a/app/src/main/java/dev/equwal/assistkey/license/License.kt
+++ b/app/src/main/java/dev/equwal/assistkey/license/License.kt
@@ -29,7 +29,13 @@ import java.security.MessageDigest
  */
 object License {
 
-    enum class Tier { LICENSED, BETA, TRIAL, LOCKED }
+    /**
+     * NO_STORE: the device has no Google Play, so there is no way to buy. The
+     * app is then free and complete. A user can get this tier on purpose by
+     * turning Google Play off. The owner accepts that: the users who can pay
+     * have Google Play, and most of them do not do this.
+     */
+    enum class Tier { LICENSED, NO_STORE, BETA, TRIAL, LOCKED }
 
     data class State(
         val tier: Tier,
@@ -97,21 +103,34 @@ object License {
         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 state = decide(owned, storePresent(c), pro, beta, now, firstRun, BuildConfig.BETA_EXPIRES_MS)
+        if (state.tier == Tier.BETA && !p.getBoolean(K_TESTER, false)) p.edit().putBoolean(K_TESTER, true).apply()
+        return state
+    }
+
+    /** The rules, with no Android types, so that a test can run them. */
+    fun decide(
+        owned: Set<String>,
+        storePresent: Boolean,
+        pro: Int,
+        beta: Int,
+        now: Long,
+        firstRun: Long,
+        betaExpires: Long
+    ): State {
+        if (owned.any { it in Sku.LICENCES }) return State(Tier.LICENSED)
+        if (!storePresent) return State(Tier.NO_STORE)
+
         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)
+            else -> now < betaExpires
         }
+        if (open) return State(Tier.BETA, betaConfirmedByPlay = beta == Seen.FOUND)
 
         val left = firstRun + TRIAL_DAYS * DAY_MS - now
         if (left > 0) {
@@ -120,6 +139,12 @@ object License {
         return State(Tier.LOCKED)
     }
 
+    /** True when the Google Play app is installed and not turned off. */
+    private fun storePresent(c: Context): Boolean =
+        runCatching { c.packageManager.getApplicationInfo(PLAY_STORE, 0).enabled }.getOrDefault(false)
+
+    private const val PLAY_STORE = "com.android.vending"
+
     private fun firstRunAt(p: SharedPreferences, now: Long): Long {
         val at = p.getLong(K_FIRST_RUN, 0L)
         if (at in 1..now) return at
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
index d784163..8bdc87b 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
@@ -38,8 +38,11 @@ class LicenseActivity : Activity() {
         val state = License.state(this)
         val col = Ui.page(this, "Licence")
         status(col, state)
-        if (state.tier != License.Tier.LICENSED) buy(col, state)
-        restore(col)
+        // With no Google Play there is nothing to buy and nothing to restore.
+        if (state.tier != License.Tier.NO_STORE) {
+            if (state.tier != License.Tier.LICENSED) buy(col, state)
+            restore(col)
+        }
         col.note("Version " + BuildConfig.VERSION_NAME)
     }
 
@@ -48,6 +51,10 @@ class LicenseActivity : Activity() {
     private fun status(col: LinearLayout, state: License.State) {
         when (state.tier) {
             License.Tier.LICENSED -> col.header("Unlocked")
+            License.Tier.NO_STORE -> {
+                col.header("Free on this device")
+                col.note("No Google Play here, so everything is unlocked.")
+            }
             License.Tier.BETA -> {
                 col.header("Beta - free for now")
                 col.note("When the beta closes, remapping stops until you buy.")
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt
index fa24f90..8a96919 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt
@@ -70,6 +70,7 @@ object Summary {
     /** The licence chip in the app bar. Short, because it sits beside the name. */
     fun licenceChip(tier: License.Tier, trialDaysLeft: Int): String = when (tier) {
         License.Tier.LICENSED -> "Unlocked"
+        License.Tier.NO_STORE -> "Free"
         License.Tier.BETA -> "Beta"
         License.Tier.TRIAL -> count(trialDaysLeft, "day") + " left"
         License.Tier.LOCKED -> "Locked"
diff --git a/app/src/test/java/dev/equwal/assistkey/license/LicenseDecideTest.kt b/app/src/test/java/dev/equwal/assistkey/license/LicenseDecideTest.kt
new file mode 100644
index 0000000..ff306a1
--- /dev/null
+++ b/app/src/test/java/dev/equwal/assistkey/license/LicenseDecideTest.kt
@@ -0,0 +1,61 @@
+package dev.equwal.assistkey.license
+
+import dev.equwal.assistkey.license.License.Seen
+import dev.equwal.assistkey.license.License.Tier
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Random
+
+class LicenseDecideTest {
+
+    private val day = 24L * 60 * 60 * 1000
+    private val expires = 1_000 * day
+
+    private fun decide(
+        owned: Set<String> = emptySet(),
+        store: Boolean = true,
+        pro: Int = Seen.UNKNOWN,
+        beta: Int = Seen.UNKNOWN,
+        now: Long = expires + 100 * day,
+        firstRun: Long = expires
+    ) = License.decide(owned, store, pro, beta, now, firstRun, expires)
+
+    @Test fun `a device with no Google Play has everything, for ever`() {
+        assertEquals(Tier.NO_STORE, decide(store = false).tier)
+        assertTrue(decide(store = false).active)
+        // The same device with Google Play is locked at this date.
+        assertEquals(Tier.LOCKED, decide(store = true).tier)
+    }
+
+    @Test fun `a bought licence comes first, with or without Google Play`() {
+        assertEquals(Tier.LICENSED, decide(owned = setOf(Sku.PRO), store = false).tier)
+        assertEquals(Tier.LICENSED, decide(owned = setOf(Sku.PRO_TESTER)).tier)
+        // The beta flag is not a licence.
+        assertEquals(Tier.LOCKED, decide(owned = setOf(Sku.BETA_OPEN)).tier)
+    }
+
+    @Test fun `with Google Play the old rules hold - beta, then trial, then locked`() {
+        assertEquals(Tier.BETA, decide(now = expires - 1).tier)
+        assertEquals(Tier.BETA, decide(beta = Seen.FOUND).tier)
+        assertEquals(Tier.TRIAL, decide(now = expires + day, firstRun = expires).tier)
+        assertEquals(7, decide(now = expires, firstRun = expires).trialDaysLeft)
+        assertEquals(Tier.LOCKED, decide(pro = Seen.FOUND, beta = Seen.NOT_FOUND, now = 0, firstRun = -30 * day).tier)
+    }
+
+    @Test fun `the app is locked only where there is a store to buy it from`() {
+        val r = Random(99)
+        val seen = listOf(Seen.UNKNOWN, Seen.FOUND, Seen.NOT_FOUND)
+        repeat(2000) {
+            val store = r.nextBoolean()
+            val owned = if (r.nextInt(4) == 0) setOf(Sku.PRO) else emptySet()
+            val state = License.decide(
+                owned, store, seen[r.nextInt(3)], seen[r.nextInt(3)],
+                r.nextInt(3000) * day, r.nextInt(3000) * day, expires
+            )
+            if (!store) assertTrue(state.active)
+            if (state.tier == Tier.LOCKED) assertTrue(store && owned.isEmpty())
+            if (owned.isNotEmpty()) assertEquals(Tier.LICENSED, state.tier)
+        }
+    }
+}
diff --git a/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt
index bd8c50f..bd89474 100644
--- a/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt
+++ b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt
@@ -70,6 +70,7 @@ class SummaryTest {
     @Test
     fun `the licence chip stays short`() {
         assertEquals("Unlocked", Summary.licenceChip(License.Tier.LICENSED, 0))
+        assertEquals("Free", Summary.licenceChip(License.Tier.NO_STORE, 0))
         assertEquals("Beta", Summary.licenceChip(License.Tier.BETA, 0))
         assertEquals("1 day left", Summary.licenceChip(License.Tier.TRIAL, 1))
         assertEquals("7 days left", Summary.licenceChip(License.Tier.TRIAL, 7))
diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md
index d583f67..ba47b79 100644
--- a/docs/MONETIZATION.md
+++ b/docs/MONETIZATION.md
@@ -1,5 +1,13 @@
 # Monetization models for Rebind
 
+## Decision of the owner, 2026-09-21
+
+A device with no Google Play gets the full app at no charge. The owner accepts
+that some users turn Google Play off to get this. The US$9.99 licence for the
+`full` build is dropped. Income comes from the Pro unlock on Google Play and
+from a tip link (Ko-fi) with no feature behind it. The text below is the
+research as it was written, before this decision.
+
 ## Recommendation
 
 Keep the free core for everyone. Sell Pro through two one-time unlocks, not a