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