diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b0877b0..041ebb4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -6,8 +6,7 @@ name: security # arreglo disponible (trivy), # - la imagen del contenedor arrastra una vulnerabilidad alta/critica con # arreglo disponible (trivy), -# - los tests del servidor (`:server:test`) fallan, incluidos los de la -# migracion V4 y los entitlements. +# - the server tests (`:server:test`) fail. # Las vulnerabilidades aceptadas temporalmente van por ID de CVE en # .github/trivyignore (nunca comodines): una NUEVA sigue fallando el build. # Las acciones van pinneadas por SHA (integridad de la cadena de CI, A08) y trivy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b350051..f1c47fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,8 @@ Thanks for helping. Bug reports and pull requests are welcome. ## Before you open a pull request - Run the tests for the modules you touched: - `./gradlew :engine:jvmTest`, `./gradlew :server:test`, `./gradlew :androidApp:testDebugUnitTest` - and `./gradlew :androidApp:lintDebug`. + `./gradlew :engine:jvmTest`, `./gradlew :server:test`, `./gradlew :androidApp:testFdroidDebugUnitTest :androidApp:testPlaystoreDebugUnitTest` + and `./gradlew :androidApp:lintFdroidDebug :androidApp:lintPlaystoreDebug`. - Keep a pull request to one fix or feature. - Commit messages follow `type(scope): description`, for example `fix(engine): keep schwa in unstressed syllables`. diff --git a/NOTICE b/NOTICE index 106025d..5097e55 100644 --- a/NOTICE +++ b/NOTICE @@ -13,9 +13,8 @@ details. Additional permission under GNU AGPL version 3 section 7: If you modify this program, or any covered work, by linking or combining it -with the Google Play Billing Library, Google Play services, the Google Play -In-App Review library, the Google Identity (googleid) library or JUnit (or -modified versions of those libraries), containing parts covered by the terms +with Google Play services, the Google Identity (googleid) library or JUnit +(or modified versions of those libraries), containing parts covered by the terms of their respective licenses, the licensors of this program grant you additional permission to convey the resulting work. @@ -188,19 +187,16 @@ Coil (io.coil-kt:coil-compose) Copyright: Coil Contributors License: Apache-2.0 (see licenses/Apache-2.0.txt) -Google Play Billing Library (com.android.billingclient:billing) +Google Play services (com.google.android.gms:play-services-*) and Google +Identity (com.google.android.libraries.identity.googleid:googleid) Copyright: Google LLC License: Android Software Development Kit License (https://developer.android.com/studio/terms.html) - Used as: in-app subscriptions. This SDK is NOT Apache-2.0; its use is - governed by the Android SDK License. - -Google Play In-App Review (com.google.android.play:review-ktx) - Copyright: Google LLC - License: Play Core Software Development Kit Terms of Service - (https://developer.android.com/guide/playcore/in-app-review) - Used as: in-app review prompt. This SDK is NOT Apache-2.0; its use is - governed by the Play Core SDK Terms of Service. + Used as: optional Sign in with Google, to carry an account to a new + phone, in the Google Play build (`playstore` flavor) only. The + F-Droid build (`fdroid` flavor) contains none of them. These SDKs + are NOT Apache-2.0; their use is governed by the Android SDK + License. Test-only libraries (not distributed in the app): JUnit 4 (junit:junit) @@ -237,7 +233,7 @@ CHANGES The wav2vec2 model is used unmodified except for the standard fp16 ONNX conversion performed by onnx-community. No third-party component is modified -by Glosso Pro. +by Glosso. This NOTICE is a summary. The app itself ships the machine-readable catalogue at assets/licenses.json, generated from the **resolved runtime classpath** (not only diff --git a/README.md b/README.md index d1c41eb..fcb5bfa 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,16 @@ exactly which sound to fix. Android first, with an iOS app in progress. Language The same phonetic engine runs on the phone (offline, ONNX Runtime) and on the server, so a take gets the same score in both places. +Glosso is free for everyone, with no paid tier. Support is a voluntary donation. + ## Repository layout | Module | What it is | |---|---| | `:engine` | Kotlin Multiplatform phonetic engine: G2P, wav2vec2 phoneme recognition (ONNX), speech gate, phonetic comparison and feedback. The only place a score is computed. | -| `:server` | Ktor API (`/v1/score`, content, assets, product config, billing verification). Reuses `:engine`. | +| `:server` | Ktor API (`/v1/score`, content, assets, product config, accounts). Reuses `:engine`. | | `:shared` | The app itself in Compose Multiplatform (screens, view models, stores, API client), shared by Android and iOS. | -| `:androidApp` | Android shell: activity, on-device scorer, download service, Play Billing. | +| `:androidApp` | Android shell: activity, on-device scorer, download service. Two flavors: `playstore` and `fdroid` (no Google libraries). | | `iosApp/` | Xcode project that runs the shared app on iOS. | | `deploy/` | Dockerfile, Compose stack, Caddy and monitoring config for self-hosting the server. | | `tools/` | Asset download, scoring bench, score parity, content pipeline and license tooling. | @@ -43,15 +45,24 @@ tools/fetch-assets.sh # Tests ./gradlew :engine:jvmTest ./gradlew :server:test -./gradlew :androidApp:testDebugUnitTest +./gradlew :androidApp:testFdroidDebugUnitTest :androidApp:testPlaystoreDebugUnitTest # Run the server locally on :8080 (serves data/assets) ./gradlew :server:run # Build the Android app against the local server (from the emulator) -./gradlew :androidApp:assembleDebug -PglossoApiBase=http://10.0.2.2:8080 +./gradlew :androidApp:assembleFdroidDebug -PglossoApiBase=http://10.0.2.2:8080 + +# Release builds of each flavor (unsigned without a keystore) +./gradlew :androidApp:assembleFdroidRelease # F-Droid: no Google libraries +./gradlew :androidApp:bundlePlaystoreRelease # Google Play ``` +The app has two flavors. `fdroid` contains no non-free library: no Google Play +services and no Sign in with Google (so "Change phone" is hidden), and usage +statistics start off. `playstore` is the Google Play build with Sign in with +Google. See `docs/FDROID.md`. + Without `-PglossoApiBase` the app talks to the public Glosso API. Build inputs are Gradle properties documented in `androidApp/build.gradle.kts`. The server runs with only the assets on disk; PostgreSQL, Redis and the TTS providers are optional and each feature turns itself off @@ -71,8 +82,10 @@ that are not part of the public repository. - `docs/ENGINE-BENCHMARK.md`, `docs/SCORING-DECISION-LAYER.md`, `docs/PHONEME_PROFILE.md` - scoring - `docs/MULTI-ACCENT.md` - scoring against whole accent systems - `docs/CONTENT-ENGINE.md`, `docs/CONTENT_POOL.md`, `docs/TTS-LAYER.md`, `docs/TTS-CACHE.md` - content and audio generation -- `docs/PRODUCT-CONFIG-CONTRACT.md`, `docs/BILLING.md`, `docs/GOOGLE-SIGNIN.md` - plans, billing and accounts +- `docs/PRODUCT-CONFIG-CONTRACT.md`, `docs/GOOGLE-SIGNIN.md` - product config and accounts +- `docs/BILLING.md` - why there is no paid tier - `docs/IOS-DEV.md`, `docs/IOS-ENGINE.md` - iOS +- `docs/FDROID.md` - the F-Droid flavor and the rules it follows - `docs/PRIVACY.md`, `docs/LICENSING.md` - privacy and third-party licensing ## Contributing @@ -84,6 +97,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Glosso is free software under the **GNU Affero General Public License v3.0 or later** ([LICENSE](LICENSE)). If you run a modified version of the server for others, you must offer them its source. [NOTICE](NOTICE) lists the third-party components and their licenses, and an -additional permission for linking with the Google Play libraries the Android app uses. +additional permission for linking with the Google Play libraries the `playstore` build uses. The Glosso name and logo are not covered by the license: forks must use their own. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c7a2115..698c2e1 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,5 +1,4 @@ import java.util.Properties -import java.util.zip.ZipFile import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -99,14 +98,6 @@ android { // carry every AndroidX/Play Services translation. resourceConfigurations += listOf("en", "es", "fr", "de") - // The subscription product id is a build input because it only exists once - // the app is configured in Play Console. It follows the published - // applicationId so the listing, the package and the product agree: - // ./gradlew :androidApp:assembleDebug -PglossoSubscriptionId=me.shirobyte42.glosso.pro - val subscriptionId = providers.gradleProperty("glossoSubscriptionId") - .getOrElse("me.shirobyte42.glosso.pro") - buildConfigField("String", "SUBSCRIPTION_PRODUCT_ID", "\"$subscriptionId\"") - // Extra points for rhythm and intonation (engine ProsodyBonus), computed // on-device against the item's reference audio. Off by default, like // ProsodyBonus.ENABLED_BY_DEFAULT; switch it on for a build with @@ -132,6 +123,30 @@ android { buildConfigField("boolean", "STORE_SCREENSHOTS", storeScreenshots.toString()) } + // Two distributions of the same app. `playstore` is the Google Play build and + // keeps Sign in with Google (Play services). `fdroid` ships no non-free + // library: no Play services, no googleid, so "Change phone" is hidden, and + // usage statistics start off until the learner turns them on (F-Droid's + // Tracking anti-feature). See docs/FDROID.md. + flavorDimensions += "distribution" + productFlavors { + create("playstore") { + dimension = "distribution" + buildConfigField("boolean", "USAGE_STATISTICS_DEFAULT", "true") + } + create("fdroid") { + dimension = "distribution" + buildConfigField("boolean", "USAGE_STATISTICS_DEFAULT", "false") + } + } + + // The dependency metadata block AGP adds to the APK is encrypted for Google + // Play; F-Droid's scanner flags it and it cannot be reproduced. Play reads it + // from the bundle, which keeps it. + dependenciesInfo { + includeInApk = false + } + buildFeatures { compose = true buildConfig = true @@ -154,18 +169,11 @@ android { buildTypes { debug { - // Debug builds use the deterministic fake repository by default, so the - // whole flow can be exercised without Play Console. Point at the real - // Play Billing client with -PglossoBilling=real once a license tester - // build is installed from a Play testing track. - val fake = providers.gradleProperty("glossoBilling").getOrElse("fake") != "real" - buildConfigField("boolean", "BILLING_FAKE", fake.toString()) - // Upgrade testing: sign the debuggable build with the published release // key so it can be installed *over* glosso-studio and inspected with // `run-as me.shirobyte42.glosso`. Off by default so ordinary debug // builds keep their throwaway debug key: - // ./gradlew :androidApp:installDebug -PglossoDebugReleaseSigning=true + // ./gradlew :androidApp:installPlaystoreDebug -PglossoDebugReleaseSigning=true if (providers.gradleProperty("glossoDebugReleaseSigning").getOrElse("false") == "true" && releaseSigning != null ) { @@ -186,8 +194,6 @@ android { ) // Unsigned when no keystore is configured, so a clean clone still builds. if (releaseSigning != null) signingConfig = releaseSigning - // A release build is never allowed to fall back to the fake. - buildConfigField("boolean", "BILLING_FAKE", "false") } } } @@ -229,10 +235,13 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material.icons.extended) // Coil 3 (images, SVG) comes with :shared, which owns the image loader. - implementation(libs.billing) - // Google sign-in (Credential Manager, googleid) comes with :shared's androidMain. debugImplementation(libs.compose.ui.tooling) + // Sign in with Google (Credential Manager + Play services): Google Play build only. + "playstoreImplementation"(libs.androidx.credentials) + "playstoreImplementation"(libs.androidx.credentials.play.services.auth) + "playstoreImplementation"(libs.googleid) + testImplementation(libs.junit) testImplementation(libs.robolectric) testImplementation(libs.kotlinx.coroutines.test) @@ -251,15 +260,23 @@ dependencies { // --- Licensing ----------------------------------------------------------------- // The APK must attribute everything it actually ships, including transitive -// dependencies (Play Services/Firebase arrive through the billing library). The -// declared-dependency list is not enough, so these tasks resolve the real debug -// runtime graph and keep a committed lock for tools/licensing/collect.py, which -// reads each artifact's POM for its licence. -val licenseLock = rootProject.layout.projectDirectory.file("tools/licensing/runtime-classpath.txt") - -fun Project.resolvedRuntimeCoordinates(): List = - configurations.getByName("debugRuntimeClasspath").incoming.resolutionResult.allComponents +// dependencies (Play Services arrives through Google sign-in in the playstore +// flavor). The declared-dependency list is not enough, so these tasks resolve +// the real debug runtime graph of each flavor and keep one committed lock per +// flavor for tools/licensing/collect.py, which reads each artifact's POM for its +// licence. The catalogue lives in :shared, which has no flavors, so it covers +// the union. Each flavor's build checks only its own lock, so the F-Droid +// build still passes after its recipe strips the Play-only dependencies. +val licensedFlavors = listOf("fdroid", "playstore") + +fun licenseLock(flavor: String): File = rootProject.file("tools/licensing/runtime-classpath-$flavor.txt") + +fun Project.runtimeComponentIds(flavor: String): List = + configurations.getByName("${flavor}DebugRuntimeClasspath").incoming.resolutionResult.allComponents .mapNotNull { it.id as? org.gradle.api.artifacts.component.ModuleComponentIdentifier } + +fun Project.resolvedRuntimeCoordinates(flavor: String): List = + runtimeComponentIds(flavor) .map { "${it.group}:${it.module}:${it.version}" } .distinct() .sorted() @@ -270,11 +287,9 @@ fun Project.resolvedRuntimeCoordinates(): List = * metadata, so without this the catalogue depends on what an earlier build * happened to download and comes out different on another machine. */ -fun Project.fetchRuntimePoms() { - val ids = configurations.getByName("debugRuntimeClasspath").incoming.resolutionResult.allComponents - .mapNotNull { it.id as? org.gradle.api.artifacts.component.ModuleComponentIdentifier } +fun Project.fetchRuntimePoms(flavor: String) { dependencies.createArtifactResolutionQuery() - .forComponents(ids) + .forComponents(runtimeComponentIds(flavor)) .withArtifacts(org.gradle.maven.MavenModule::class.java, org.gradle.maven.MavenPomArtifact::class.java) .execute() .resolvedComponents @@ -283,35 +298,46 @@ fun Project.fetchRuntimePoms() { val licensesRuntimeClasspath by tasks.registering { group = "licensing" - description = "Write the resolved debug runtime classpath lock for tools/licensing/collect.py" + description = "Write the resolved debug runtime classpath lock of every flavor for tools/licensing/collect.py" doLast { - val coordinates = project.resolvedRuntimeCoordinates() - project.fetchRuntimePoms() - val file = licenseLock.asFile - file.parentFile.mkdirs() - file.writeText(coordinates.joinToString("\n", postfix = "\n")) - logger.lifecycle("licensing: ${coordinates.size} runtime modules -> ${file.relativeTo(rootProject.projectDir)}") + licensedFlavors.forEach { flavor -> + val coordinates = project.resolvedRuntimeCoordinates(flavor) + project.fetchRuntimePoms(flavor) + val file = licenseLock(flavor) + file.parentFile.mkdirs() + file.writeText(coordinates.joinToString("\n", postfix = "\n")) + logger.lifecycle("licensing: ${coordinates.size} $flavor runtime modules -> ${file.relativeTo(rootProject.projectDir)}") + } } } -val licensesRuntimeClasspathCheck by tasks.registering { - group = "licensing" - description = "Fail if the runtime classpath lock is stale (run :androidApp:licensesRuntimeClasspath)" - outputs.upToDateWhen { false } - doLast { - val coordinates = project.resolvedRuntimeCoordinates() - project.fetchRuntimePoms() - val committed = licenseLock.asFile.takeIf { it.isFile }?.readLines()?.filter { it.isNotBlank() } ?: emptyList() - if (coordinates != committed) { - throw GradleException( - "tools/licensing/runtime-classpath.txt is out of date. Refresh the runtime lock and the app catalogue:\n" + - " ./gradlew :androidApp:licensesRuntimeClasspath\n" + - " python3 tools/licensing/collect.py", - ) +val licensesRuntimeClasspathChecks = licensedFlavors.associateWith { flavor -> + tasks.register("licensesRuntimeClasspathCheck${flavor.replaceFirstChar(Char::uppercase)}") { + group = "licensing" + description = "Fail if the $flavor runtime classpath lock is stale (run :androidApp:licensesRuntimeClasspath)" + outputs.upToDateWhen { false } + doLast { + val coordinates = project.resolvedRuntimeCoordinates(flavor) + project.fetchRuntimePoms(flavor) + val lock = licenseLock(flavor) + val committed = lock.takeIf { it.isFile }?.readLines()?.filter { it.isNotBlank() } ?: emptyList() + if (coordinates != committed) { + throw GradleException( + "${lock.relativeTo(rootProject.projectDir)} is out of date. Refresh the runtime locks and the app catalogue:\n" + + " ./gradlew :androidApp:licensesRuntimeClasspath\n" + + " python3 tools/licensing/collect.py", + ) + } } } } +val licensesRuntimeClasspathCheck by tasks.registering { + group = "licensing" + description = "Fail if any flavor's runtime classpath lock is stale" + dependsOn(licensesRuntimeClasspathChecks.values) +} + /** * Run a licensing script, echo its output to the Gradle log and fail the build * if it exits non-zero. Fail closed: a missing interpreter or a crashed script @@ -347,7 +373,6 @@ fun Project.runLicensingPython(script: File, vararg args: String) { val licensesCatalogueCheck by tasks.registering { group = "licensing" description = "Fail if the in-app licence catalogue is stale, incomplete or carries a prohibited licence" - dependsOn(licensesRuntimeClasspathCheck) outputs.upToDateWhen { false } doLast { project.runLicensingPython(rootProject.file("tools/licensing/collect.py"), "--check") @@ -355,99 +380,9 @@ val licensesCatalogueCheck by tasks.registering { } } -tasks.named("preBuild") { dependsOn(licensesRuntimeClasspathCheck, licensesCatalogueCheck) } - -// --- Release entitlement guard ------------------------------------------------- -// The debug build ships FakeSubscriptionRepository, which starts Glosso Pro -// unlocked. That class lives only in src/debug, so it must be absent from every -// release artifact that can reach a device: the APK and the AAB, which is what -// Play actually publishes. Source audits (:androidApp:test) are weaker than -// looking at the binary, so these tasks unzip the packaged artifact and fail if -// the fake ever leaks into a dex. Each runs right after its packaging task, so -// `assembleRelease` guards the APK and `bundleRelease` guards the AAB. This pins -// that release never carries a client-side unlock; it is not the anti-tamper -// defence, because the client does not consult server verification yet (the -// entitlement limitation is tracked in docs/BILLING.md). -val releaseEntitlementForbidden = "FakeSubscriptionRepository" - -fun ByteArray.containsSequence(pattern: ByteArray): Boolean { - if (pattern.isEmpty() || pattern.size > size) return pattern.isEmpty() - outer@ for (i in 0..(size - pattern.size)) { - for (j in pattern.indices) if (this[i + j] != pattern[j]) continue@outer - return true - } - return false -} - -/** Dex entry names of [artifact] that carry [forbidden] as a raw string. */ -fun dexHitsFor(artifact: File, forbidden: String): List { - val marker = forbidden.toByteArray(Charsets.UTF_8) - val hits = mutableListOf() - ZipFile(artifact).use { zip -> - val entries = zip.entries() - while (entries.hasMoreElements()) { - val entry = entries.nextElement() - if (!entry.name.endsWith(".dex")) continue - val bytes = zip.getInputStream(entry).use { it.readBytes() } - if (bytes.containsSequence(marker)) hits += entry.name - } - } - return hits -} - -val verifyReleaseApkEntitlement by tasks.registering { - group = "verification" - description = "Fail if the release APK contains the debug fake billing repository" - dependsOn("packageRelease") - val artifactDir = layout.buildDirectory.dir("outputs/apk/release") - inputs.dir(artifactDir).withPropertyName("releaseApkDir") - outputs.upToDateWhen { false } - doLast { - val dir = artifactDir.get().asFile - val apk = dir.listFiles { file -> file.isFile && file.extension == "apk" }?.firstOrNull() - ?: throw GradleException("release entitlement guard: no APK in $dir") - val hits = dexHitsFor(apk, releaseEntitlementForbidden) - if (hits.isNotEmpty()) { - throw GradleException( - "release entitlement guard: ${apk.name} contains the debug fake " + - "($releaseEntitlementForbidden in ${hits.joinToString()}). Debug-only billing " + - "must never reach release.", - ) - } - logger.lifecycle("release entitlement guard: ${apk.name} carries no debug billing code") - } +tasks.named("preBuild") { dependsOn(licensesCatalogueCheck) } +licensedFlavors.forEach { flavor -> + val check = licensesRuntimeClasspathChecks.getValue(flavor) + val variantPrefix = "pre${flavor.replaceFirstChar(Char::uppercase)}" + tasks.matching { it.name.startsWith(variantPrefix) && it.name.endsWith("Build") }.configureEach { dependsOn(check) } } - -val verifyReleaseBundleEntitlement by tasks.registering { - group = "verification" - description = "Fail if the release AAB (what Play publishes) contains the debug fake billing repository" - dependsOn("bundleRelease") - val artifactDir = layout.buildDirectory.dir("outputs/bundle/release") - inputs.dir(artifactDir).withPropertyName("releaseBundleDir") - outputs.upToDateWhen { false } - doLast { - val dir = artifactDir.get().asFile - val aab = dir.listFiles { file -> file.isFile && file.extension == "aab" }?.firstOrNull() - ?: throw GradleException("release entitlement guard: no AAB in $dir") - val hits = dexHitsFor(aab, releaseEntitlementForbidden) - if (hits.isNotEmpty()) { - throw GradleException( - "release entitlement guard: ${aab.name} contains the debug fake " + - "($releaseEntitlementForbidden in ${hits.joinToString()}). Debug-only billing " + - "must never reach release.", - ) - } - logger.lifecycle("release entitlement guard: ${aab.name} carries no debug billing code") - } -} - -// Both artifacts in one command; the per-variant guards above are what CI and the -// packaging tasks actually run. -val verifyReleaseEntitlement by tasks.registering { - group = "verification" - description = "Fail if any release artifact (APK or AAB) contains the debug fake billing repository" - dependsOn(verifyReleaseApkEntitlement, verifyReleaseBundleEntitlement) -} - -tasks.matching { it.name == "assembleRelease" }.configureEach { finalizedBy(verifyReleaseApkEntitlement) } -tasks.matching { it.name == "bundleRelease" }.configureEach { finalizedBy(verifyReleaseBundleEntitlement) } diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro index c9b941d..6bb100a 100644 --- a/androidApp/proguard-rules.pro +++ b/androidApp/proguard-rules.pro @@ -12,16 +12,6 @@ # kotlinx.serialization reads @Serializable and @SerialName from annotations. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault -# --- Release entitlement guard --- -# The build fails if the debug fake ever reaches a release artifact: -# verifyReleaseApkEntitlement / verifyReleaseBundleEntitlement (build.gradle.kts) -# unzip the APK/AAB and look for the class name "FakeSubscriptionRepository". -# R8 renames com.glosso.* on release, so without this the guard would keep -# searching for a name that no longer exists and would silently stop protecting -# anything. Keeping the *name* (not the class: it is still shrunk when -# unreachable) is what keeps the guard live. --keepnames class **FakeSubscriptionRepository - # --- ONNX Runtime --- # ai.onnxruntime.** mirrors the ONNX C++ objects. The runtime is reached from # two directions: the JNI layer resolves classes and `native` methods by name, diff --git a/androidApp/src/debug/java/com/glosso/app/billing/FakeBilling.kt b/androidApp/src/debug/java/com/glosso/app/billing/FakeBilling.kt deleted file mode 100644 index bdb9d61..0000000 --- a/androidApp/src/debug/java/com/glosso/app/billing/FakeBilling.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.glosso.app.billing - -/** - * Debug-only hook for [BillingProvider]. The fake lives in the debug source set, - * so neither the class nor its development price is packaged into a release - * artifact (APK or AAB). Point at Play in debug with `-PglossoBilling=real`. - * - * Convenience for day-to-day development: the debug build boots with Glosso Pro - * already unlocked ([SubscriptionStatus.ACTIVE]), so every Pro surface is - * reachable without walking the fake purchase flow first. This only changes the - * wiring here; release never uses the fake. To test the paywall and the free/Pro - * boundary again, either drop `initialStatus` (the [FakeSubscriptionRepository] - * default is [SubscriptionStatus.NONE]), or call `reset()` from the debug test - * hooks at runtime. - */ -internal fun debugFakeBilling(): SubscriptionRepository? = - FakeSubscriptionRepository(initialStatus = SubscriptionStatus.ACTIVE) diff --git a/androidApp/src/debug/java/com/glosso/app/billing/FakeSubscriptionRepository.kt b/androidApp/src/debug/java/com/glosso/app/billing/FakeSubscriptionRepository.kt deleted file mode 100644 index 16bed54..0000000 --- a/androidApp/src/debug/java/com/glosso/app/billing/FakeSubscriptionRepository.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.glosso.app.billing - -import android.app.Activity -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * A deterministic stand-in for Play Billing, used by debug builds and tests. - * - * Google does not ship an official test double, and a real purchase can only be - * exercised from a Play testing track with license testers, so this is the only - * way to walk the whole paywall and gate flow locally. It never talks to Google - * and never charges anything. - * - * The plan it exposes is a development placeholder, not a real price. A release - * build always uses [PlaySubscriptionRepository]. - */ -class FakeSubscriptionRepository( - private val planValue: SubscriptionPlan = devPlan, - initialStatus: SubscriptionStatus = SubscriptionStatus.NONE, -) : SubscriptionRepository { - - private val _status = MutableStateFlow(initialStatus) - override val status: StateFlow = _status.asStateFlow() - - private val _plan = MutableStateFlow(planValue) - override val plan: StateFlow = _plan.asStateFlow() - - private val _loading = MutableStateFlow(false) - override val loading: StateFlow = _loading.asStateFlow() - - private val _error = MutableStateFlow(null) - override val error: StateFlow = _error.asStateFlow() - - override fun start() { - _loading.value = false - } - - override fun refresh(restore: Boolean) { - _loading.value = false - _error.value = null - } - - /** A fake purchase succeeds instantly and starts the free trial. */ - override fun subscribe(activity: Activity) { - if (_status.value == SubscriptionStatus.NONE) { - _status.value = if (planValue.hasFreeTrial) SubscriptionStatus.TRIAL - else SubscriptionStatus.ACTIVE - } - } - - override fun clearError() { - _error.value = null - } - - // Test and debug hooks. Not part of the production interface. - - /** Simulates completing a purchase (the CTA in a debug build does this). */ - fun simulateSubscribe() { - if (_status.value == SubscriptionStatus.NONE) { - _status.value = if (planValue.hasFreeTrial) SubscriptionStatus.TRIAL - else SubscriptionStatus.ACTIVE - } - } - - /** Simulates the trial elapsing into a paid, renewing subscription. */ - fun simulateActivate() { - _status.value = SubscriptionStatus.ACTIVE - } - - /** Simulates cancelling; access continues, like Play's real behavior. */ - fun simulateCancel() { - _status.value = SubscriptionStatus.CANCELED - } - - /** Simulates a payment problem Play put into grace. */ - fun simulateGrace() { - _status.value = SubscriptionStatus.GRACE - } - - /** Simulates a deferred (pending) payment. */ - fun simulatePending() { - _status.value = SubscriptionStatus.PENDING - } - - /** Simulates a network failure, to exercise the error path. */ - fun simulateError(message: String = "No se pudo conectar con Google Play.") { - _error.value = message - } - - /** Back to a fresh install. */ - fun reset() { - _status.value = SubscriptionStatus.NONE - _error.value = null - _loading.value = false - } - - companion object { - /** - * Placeholder offer for development only. Price and trial are made up; the - * real values always come from Play. - */ - val devPlan = SubscriptionPlan( - // The published identity's product id (see docs/BILLING.md); it only - // shapes the manage-subscription deep link in debug. - productId = "me.shirobyte42.glosso.pro", - title = "Glosso Pro (desarrollo)", - description = "Plan de prueba del modo fake, no es un precio real.", - offerToken = "fake-offer-token", - basePlanId = "fake-base-plan", - offerId = "fake-trial-offer", - priceFormatted = "6,99 €", - recurringPeriodIso = "P1M", - freeTrialPeriod = "P1M", - ) - } -} diff --git a/androidApp/src/fdroid/java/com/glosso/app/Flavor.kt b/androidApp/src/fdroid/java/com/glosso/app/Flavor.kt new file mode 100644 index 0000000..683bc9b --- /dev/null +++ b/androidApp/src/fdroid/java/com/glosso/app/Flavor.kt @@ -0,0 +1,9 @@ +package com.glosso.app + +/** + * The F-Droid build ships no Google libraries, so it installs nothing: sign-in + * stays [UnavailableSignIn] and "Change phone" is hidden. + */ +internal object Flavor { + fun install() = Unit +} diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 1f2ee75..c137a1d 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,8 +5,6 @@ - - diff --git a/androidApp/src/main/java/com/glosso/app/GlossoApplication.kt b/androidApp/src/main/java/com/glosso/app/GlossoApplication.kt index ce59680..81aaddd 100644 --- a/androidApp/src/main/java/com/glosso/app/GlossoApplication.kt +++ b/androidApp/src/main/java/com/glosso/app/GlossoApplication.kt @@ -6,7 +6,6 @@ import coil3.PlatformContext import coil3.SingletonImageLoader import com.glosso.app.api.ApiCache import com.glosso.app.api.GlossoApi -import com.glosso.app.billing.BillingProvider import com.glosso.app.download.DownloadManager import com.glosso.app.platform.platformConnectivity import com.glosso.app.migration.StudioImport @@ -57,9 +56,10 @@ class GlossoApplication : Application(), SingletonImageLoader.Factory { Analytics.eraser = { appApi.deleteEvents() } Analytics.traceSender = { appApi.sendCrashTrace(it) } Analytics.onDeviceModel = { OnDeviceScorer.available() } + Analytics.defaultEnabled = BuildConfig.USAGE_STATISTICS_DEFAULT ProductConfigRepository.api = { appApi } OfflineStore.downloader = DownloadManager - GoogleSignIn.serverClientId = BuildConfig.GOOGLE_SERVER_CLIENT_ID + Flavor.install() } override fun newImageLoader(context: PlatformContext): ImageLoader = glossoImageLoader(this) @@ -74,13 +74,11 @@ class GlossoApplication : Application(), SingletonImageLoader.Factory { prosodyBonus = BuildConfig.PROSODY_BONUS, versionName = BuildConfig.VERSION_NAME, versionCode = BuildConfig.VERSION_CODE, - subscriptionProductId = BuildConfig.SUBSCRIPTION_PRODUCT_ID, ), connectivity = platformConnectivity(this), onDevice = OnDeviceScorer, downloader = DownloadManager, newApi = { GlossoApi() }, - subscriptions = { BillingProvider.get(this) }, ) } } diff --git a/androidApp/src/main/java/com/glosso/app/ModelPackImport.kt b/androidApp/src/main/java/com/glosso/app/ModelPackImport.kt new file mode 100644 index 0000000..b16bf79 --- /dev/null +++ b/androidApp/src/main/java/com/glosso/app/ModelPackImport.kt @@ -0,0 +1,74 @@ +package com.glosso.app + +import com.glosso.app.platform.PickedFile +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import okio.buffer +import okio.sink + +/** + * Installs the on-device model pack from files the learner picked (a copy of the + * pack fetched elsewhere, for a phone that cannot or should not download it). + * Each file is copied to a temporary file in the pack directory while its SHA-256 + * is computed, and only a file whose digest is an accepted one for a pack file is + * renamed onto that file; anything else is deleted. File names do not matter: the + * digest says which file it is, so a renamed copy still installs and a corrupt or + * foreign one never does. + */ +internal object ModelPackImport { + + enum class Outcome { + /** Every pack file is now in place. */ + COMPLETE, + + /** What was picked is valid, but a pack file is still missing. */ + INCOMPLETE, + + /** A picked file matched no pack file and the pack is still incomplete. */ + REJECTED, + } + + /** [accepted] maps each pack file name to the lowercase hex SHA-256 digests it may have. */ + fun install(dir: File, files: List, accepted: Map>): Outcome { + dir.mkdirs() + var rejected = false + files.forEachIndexed { index, file -> + val temporary = File(dir, "import-$index.part") + try { + val digest = copyHashing(file, temporary) + val target = accepted.entries.firstOrNull { digest in it.value }?.key + if (target == null) { + rejected = true + } else { + val destination = File(dir, target) + destination.delete() + if (!temporary.renameTo(destination)) throw IOException("Could not install $target") + } + } finally { + temporary.delete() + } + } + return when { + accepted.keys.all { File(dir, it).length() > 0 } -> Outcome.COMPLETE + rejected -> Outcome.REJECTED + else -> Outcome.INCOMPLETE + } + } + + private fun copyHashing(file: PickedFile, target: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.open().buffer().use { source -> + target.sink().buffer().use { sink -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = source.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + sink.write(buffer, 0, read) + } + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/androidApp/src/main/java/com/glosso/app/OnDeviceScorer.kt b/androidApp/src/main/java/com/glosso/app/OnDeviceScorer.kt index 273fbd0..e125366 100644 --- a/androidApp/src/main/java/com/glosso/app/OnDeviceScorer.kt +++ b/androidApp/src/main/java/com/glosso/app/OnDeviceScorer.kt @@ -13,6 +13,7 @@ import com.glosso.app.download.DownloadItemState import com.glosso.app.download.DownloadKind import com.glosso.app.download.DownloadManager import com.glosso.app.download.DownloadPriority +import com.glosso.app.platform.PickedFile import com.glosso.app.resources.* import com.glosso.engine.accent.AccentScoring import com.glosso.engine.accent.AccentTarget @@ -40,6 +41,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.getString /** @@ -144,6 +146,49 @@ object OnDeviceScorer : OnDeviceScoring { } } + /** + * Installs the pack from picked files. A file is accepted when its SHA-256 is + * the one pinned below (the deployed pack, so this works with no connection) + * or the one the server publishes now, the digest a download is checked + * against. + */ + override fun importPack(files: List) { + val current = _state.value + if (current.downloading || current.importing || files.isEmpty()) return + _state.value = current.copy(importing = true, error = null) + scope.launch { + val error = try { + when (ModelPackImport.install(root, files, acceptedDigests())) { + ModelPackImport.Outcome.COMPLETE -> null + ModelPackImport.Outcome.INCOMPLETE -> getString(Res.string.import_model_incomplete) + ModelPackImport.Outcome.REJECTED -> getString(Res.string.import_model_rejected) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("GlossoScorer", "scoring pack import failed", e) + getString(Res.string.import_model_failed) + } + if (error == null) OfflineModelHint.markSeen(appContext) + refresh() + _state.value = _state.value.copy(importing = false, error = error) + } + } + + private suspend fun acceptedDigests(): Map> = + PINNED_SHA256.mapValues { (name, pinned) -> + val served = withTimeoutOrNull(PUBLISHED_DIGEST_TIMEOUT_MS) { appApi.sharedAssetSha256(name) } + setOfNotNull(pinned, served?.lowercase()) + } + + /** SHA-256 of the pack the app was released with (`.sha256` on the server). */ + private val PINNED_SHA256 = mapOf( + PhonemeModelConfig.MODEL_FILE to "f3a768be3f4f1918a0cd52f51798dc50db307167d6b6dd12d0fa8ce6de1e258a", + PhonemeModelConfig.VOCAB_FILE to "d732ab2456c0c017930001dc9af0b41b3b93d25b2eb9740bf9d925508d7d87d0", + ) + + private const val PUBLISHED_DIGEST_TIMEOUT_MS = 5_000L + /** Mirrors the shared queue's model item into this scorer's state. */ internal fun onQueueState(item: DownloadItemState?) { if (item == null) return diff --git a/androidApp/src/main/java/com/glosso/app/billing/BillingProvider.kt b/androidApp/src/main/java/com/glosso/app/billing/BillingProvider.kt deleted file mode 100644 index 75b9f6f..0000000 --- a/androidApp/src/main/java/com/glosso/app/billing/BillingProvider.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.glosso.app.billing - -import android.content.Context -import com.glosso.app.BuildConfig - -/** - * Builds the one [SubscriptionRepository] for the process. Debug builds use the - * fake by default so the whole paywall and gate flow can be walked without Play; - * a license-tester build can opt into the real client with - * `-PglossoBilling=real`. Release builds always use Play. - * - * The fake itself lives in the debug source set (`debugFakeBilling`), so no fake - * code or development price is ever compiled into a release APK. - */ -object BillingProvider { - - @Volatile - private var instance: SubscriptionRepository? = null - - fun get(context: Context): SubscriptionRepository = - instance ?: synchronized(this) { - instance ?: create(context.applicationContext).also { instance = it } - } - - private fun create(context: Context): SubscriptionRepository = - if (useFake(BuildConfig.BILLING_FAKE, BuildConfig.DEBUG)) { - debugFakeBilling() ?: PlaySubscriptionRepository(context) - } else { - PlaySubscriptionRepository(context) - } - - /** - * The only condition that hands out the fake. It requires a debuggable build - * as well as the fake flag, so a production (release) build can never use the - * fake subscription even if `BILLING_FAKE` were misconfigured: [BuildConfig.DEBUG] - * is a compile-time `false` in release. Pure, so a unit test pins it. - */ - fun useFake(billingFake: Boolean, debuggable: Boolean): Boolean = - billingFake && debuggable - - /** Test seam: install a specific repository, or null to rebuild on next use. */ - fun override(repository: SubscriptionRepository?) { - synchronized(this) { instance = repository } - } -} diff --git a/androidApp/src/main/java/com/glosso/app/billing/PlaySubscriptionRepository.kt b/androidApp/src/main/java/com/glosso/app/billing/PlaySubscriptionRepository.kt deleted file mode 100644 index 4619cde..0000000 --- a/androidApp/src/main/java/com/glosso/app/billing/PlaySubscriptionRepository.kt +++ /dev/null @@ -1,438 +0,0 @@ -package com.glosso.app.billing - -import android.app.Activity -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.android.billingclient.api.AcknowledgePurchaseParams -import com.android.billingclient.api.BillingClient -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.glosso.app.Analytics -import com.glosso.app.BuildConfig -import com.glosso.app.R -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * The real Google Play Billing implementation. - * - * It owns the [BillingClient] lifecycle: connecting (and reconnecting) the - * service, restoring purchases on launch and on every resume, loading the offer so - * the paywall shows Play's own price and trial, launching the purchase flow with - * the offer token (never the bare base plan), acknowledging the purchase, and - * surfacing network or configuration failures as state instead of crashing. - * - * Honesty note: none of this can be exercised end to end locally. A real purchase - * requires the app installed from a Play testing track with license testers. In - * debug, a fake in the debug source set stands in. - */ -class PlaySubscriptionRepository( - context: Context, - private val productId: String = BuildConfig.SUBSCRIPTION_PRODUCT_ID, -) : SubscriptionRepository { - - private val appContext = context.applicationContext - private val handler = Handler(Looper.getMainLooper()) - - private val _status = MutableStateFlow(SubscriptionStatus.NONE) - override val status: StateFlow = _status.asStateFlow() - - private val _plan = MutableStateFlow(null) - override val plan: StateFlow = _plan.asStateFlow() - - private val _loading = MutableStateFlow(false) - override val loading: StateFlow = _loading.asStateFlow() - - private val _error = MutableStateFlow(null) - override val error: StateFlow = _error.asStateFlow() - - @Volatile - private var released = false - - @Volatile - private var connecting = false - - private var reconnectAttempts = 0 - - @Volatile - private var productDetails: ProductDetails? = null - - // The last purchase Play returned, kept so the status can be recomputed once - // the offer phases load (TRIAL vs ACTIVE needs them). - @Volatile - private var lastPaid: PurchaseView? = null - - @Volatile - private var lastPending: PurchaseView? = null - - // Purchases this process already sent to acknowledge, so a re-query does not - // fire the same acknowledgement repeatedly. Main-thread only. - private val acknowledgeSent = mutableSetOf() - - private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - val reason = if (result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { - "user_canceled" - } else { - "code_${result.responseCode}" - } - Analytics.track( - "purchase_failed", - mapOf("product_id" to _plan.value?.productId, "reason" to reason), - ) - } - when (result.responseCode) { - BillingClient.BillingResponseCode.OK -> - if (purchases != null) processPurchases(purchases) - - BillingClient.BillingResponseCode.USER_CANCELED -> _error.value = null - - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> { - _error.value = null - refresh(restore = true) - } - - BillingClient.BillingResponseCode.NETWORK_ERROR, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, - -> { - _error.value = appContext.getString(R.string.billing_error_offline) - scheduleReconnect() - } - - else -> _error.value = messageFor(result) - } - } - - private val billingClient = BillingClient.newBuilder(appContext) - .setListener(purchasesUpdatedListener) - // Required even though this app sells subscriptions: Play needs the client - // to declare how it will handle deferred (pending) payments. - .enablePendingPurchases( - PendingPurchasesParams.newBuilder() - .enableOneTimeProducts() - .enablePrepaidPlans() - .build(), - ) - .enableAutoServiceReconnection() - .build() - - override fun start() { - if (released) return - if (billingClient.isReady) { - onConnected() - return - } - if (connecting) return - connecting = true - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(result: BillingResult) { - connecting = false - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - reconnectAttempts = 0 - _error.value = null - onConnected() - } else { - _error.value = messageFor(result) - scheduleReconnect() - } - } - - override fun onBillingServiceDisconnected() { - connecting = false - if (!released) scheduleReconnect() - } - }) - } - - private fun onConnected() { - loadProductDetails() - queryPurchases() - } - - /** - * Loads the product and its offers. The offer that carries a free-trial - * pricing phase wins, so the flow uses its offer token and the paywall shows - * the trial Play actually configured. - */ - private fun loadProductDetails() { - _loading.value = true - val product = QueryProductDetailsParams.Product.newBuilder() - .setProductId(productId) - .setProductType(BillingClient.ProductType.SUBS) - .build() - val params = QueryProductDetailsParams.newBuilder() - .setProductList(listOf(product)) - .build() - billingClient.queryProductDetailsAsync(params) { result, queryResult -> - _loading.value = false - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - _error.value = messageFor(result) - if (isRetryable(result.responseCode)) scheduleReconnect() - return@queryProductDetailsAsync - } - val details = queryResult.productDetailsList.firstOrNull { it.productId == productId } - productDetails = details - if (details == null) { - _plan.value = null - _error.value = appContext.getString(R.string.billing_error_not_configured) - return@queryProductDetailsAsync - } - val plan = details.toPlan() - _plan.value = plan - if (plan == null) { - _error.value = appContext.getString(R.string.billing_error_no_offers) - return@queryProductDetailsAsync - } - // The offer phases are now known: recover any error and recompute the - // status, so a purchase seen before this moment is labelled right. - _error.value = null - recomputeStatus() - } - } - - override fun refresh(restore: Boolean) { - if (released) return - // A retry by the user: drop the stale error immediately. - _error.value = null - if (!billingClient.isReady) { - start() - return - } - if (_plan.value == null) loadProductDetails() - queryPurchases() - } - - /** The restore path: whatever Play holds for this account becomes the status. */ - private fun queryPurchases() { - val params = QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.SUBS) - .build() - billingClient.queryPurchasesAsync(params) { result, purchases -> - when (result.responseCode) { - BillingClient.BillingResponseCode.OK -> { - _error.value = null - processPurchases(purchases) - } - - else -> { - _error.value = messageFor(result) - if (isRetryable(result.responseCode)) scheduleReconnect() - } - } - } - } - - private fun processPurchases(purchases: List) { - val views = purchases.map { it.toView() }.filter { it.productId == productId } - - when (val selection = SubscriptionResolver.select(views)) { - is PurchaseSelection.Paid -> { - // A PURCHASED purchase wins over a pending one: it is the entitlement - // now, and it must be acknowledged even if a pending purchase exists. - lastPaid = selection.purchase - lastPending = selection.alsoPending - val paid = selection.purchase - if (!paid.acknowledged && acknowledgeSent.add(paid.token)) { - acknowledge(paid.token) - } - recomputeStatus() - } - - is PurchaseSelection.Deferred -> { - // Deferred payment: no entitlement until it completes. It is not - // acknowledged (there is nothing to acknowledge yet). - lastPaid = null - lastPending = selection.purchase - _status.value = SubscriptionStatus.PENDING - } - - PurchaseSelection.None -> { - lastPaid = null - lastPending = null - _status.value = SubscriptionStatus.NONE - } - } - } - - /** - * Recomputes the status from the last purchases. Waits for the offer phases - * (TRIAL vs ACTIVE) instead of guessing ACTIVE when the plan is not loaded. - */ - private fun recomputeStatus() { - val paid = lastPaid - if (paid == null) { - _status.value = if (lastPending != null) { - SubscriptionStatus.PENDING - } else { - SubscriptionStatus.NONE - } - return - } - val plan = _plan.value ?: return - SubscriptionResolver.statusFor(paid, plan)?.let { _status.value = it } - } - - /** Acknowledge or Play refunds the purchase after three days. */ - private fun acknowledge(token: String) { - val params = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(token) - .build() - billingClient.acknowledgePurchase(params) { result -> - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - // Let the next query retry the acknowledgement. - acknowledgeSent.remove(token) - _error.value = appContext.getString(R.string.billing_error_confirm) - handler.postDelayed({ - if (!released) refresh(restore = true) - }, 5_000) - } - } - } - - override fun subscribe(activity: Activity) { - if (released) return - if (!billingClient.isReady) { - _error.value = appContext.getString(R.string.billing_error_connecting) - start() - return - } - val details = productDetails - val plan = _plan.value - if (details == null || plan == null) { - _error.value = appContext.getString(R.string.billing_error_not_available_yet) - loadProductDetails() - return - } - - // Only the offer token: it selects the base plan plus the free-trial offer. - // No replacement/upgrade parameters are sent, so this flow can never change - // an existing subscription's proration by accident. - val productParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(details) - .setOfferToken(plan.offerToken) - .build() - - val flowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(listOf(productParams)) - .build() - val result = billingClient.launchBillingFlow(activity, flowParams) - if (result.responseCode == BillingClient.BillingResponseCode.OK) { - Analytics.track("purchase_started", mapOf("product_id" to plan.productId, "plan" to plan.basePlanId)) - _error.value = null - } else { - _error.value = messageFor(result) - } - } - - override fun clearError() { - _error.value = null - } - - /** Stops the client and cancels pending retries. Call from app shutdown. */ - fun release() { - released = true - handler.removeCallbacksAndMessages(null) - runCatching { billingClient.endConnection() } - } - - private fun scheduleReconnect() { - if (released) return - val attempt = reconnectAttempts - if (attempt >= MAX_RECONNECT_ATTEMPTS) { - _error.value = appContext.getString(R.string.billing_error_reconnect) - return - } - reconnectAttempts = attempt + 1 - val delayMillis = (1L shl attempt.coerceAtMost(5)) * 1_000L - handler.postDelayed({ - if (!released && !billingClient.isReady) start() - }, delayMillis) - } - - private fun isRetryable(code: Int): Boolean = when (code) { - BillingClient.BillingResponseCode.NETWORK_ERROR, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, - -> true - - else -> false - } - - private fun messageFor(result: BillingResult): String = when (result.responseCode) { - BillingClient.BillingResponseCode.NETWORK_ERROR -> - appContext.getString(R.string.billing_error_network) - - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> - appContext.getString(R.string.billing_error_service_unavailable) - - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> - appContext.getString(R.string.billing_error_service_disconnected) - - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> - appContext.getString(R.string.billing_error_unavailable) - - BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> - appContext.getString(R.string.billing_error_item_unavailable) - - BillingClient.BillingResponseCode.DEVELOPER_ERROR -> - appContext.getString(R.string.billing_error_developer) - - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> - appContext.getString(R.string.billing_error_already_owned) - - else -> result.debugMessage.ifBlank { - appContext.getString(R.string.billing_error_generic) - } - } - - private fun Purchase.toView(): PurchaseView = PurchaseView( - productId = products.firstOrNull().orEmpty(), - purchased = purchaseState == Purchase.PurchaseState.PURCHASED, - pending = purchaseState == Purchase.PurchaseState.PENDING, - autoRenewing = isAutoRenewing, - purchaseTimeMillis = purchaseTime, - acknowledged = isAcknowledged, - token = purchaseToken, - ) - - private fun ProductDetails.toPlan(): SubscriptionPlan? { - val offers = subscriptionOfferDetails.orEmpty() - val chosen = offers.firstOrNull { offer -> - offer.pricingPhases.pricingPhaseList.any { - it.priceAmountMicros == 0L && it.billingPeriod.isNotBlank() - } - } ?: offers.firstOrNull() ?: return null - - val phases = chosen.pricingPhases.pricingPhaseList - val trial = phases.firstOrNull { - it.priceAmountMicros == 0L && it.billingPeriod.isNotBlank() - } - val recurring = phases.lastOrNull { it.priceAmountMicros > 0L } ?: phases.lastOrNull() - - return SubscriptionPlan( - productId = productId, - title = title, - description = description, - offerToken = chosen.offerToken, - basePlanId = chosen.basePlanId, - offerId = chosen.offerId, - priceFormatted = recurring?.formattedPrice.orEmpty(), - recurringPeriodIso = recurring?.billingPeriod, - freeTrialPeriod = trial?.billingPeriod, - ) - } - - private companion object { - const val MAX_RECONNECT_ATTEMPTS = 5 - } -} diff --git a/androidApp/src/main/java/com/glosso/app/billing/SubscriptionResolver.kt b/androidApp/src/main/java/com/glosso/app/billing/SubscriptionResolver.kt deleted file mode 100644 index 44af8b7..0000000 --- a/androidApp/src/main/java/com/glosso/app/billing/SubscriptionResolver.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.glosso.app.billing - -/** - * The fields of a Play purchase that entitlement resolution needs, so the logic - * can be tested without the Play client. [PlaySubscriptionRepository] maps a - * `com.android.billingclient.api.Purchase` to this value. - */ -data class PurchaseView( - val productId: String, - val purchased: Boolean, - val pending: Boolean, - val autoRenewing: Boolean, - val purchaseTimeMillis: Long, - val acknowledged: Boolean, - val token: String, -) - -/** The outcome of choosing the purchase that decides entitlement. */ -sealed interface PurchaseSelection { - /** A paid purchase. [alsoPending] is another purchase still pending, if any. */ - data class Paid( - val purchase: PurchaseView, - val alsoPending: PurchaseView?, - ) : PurchaseSelection - - /** Only a deferred (pending) payment: no entitlement until it completes. */ - data class Deferred(val purchase: PurchaseView) : PurchaseSelection - - /** Nothing relevant on the account. */ - data object None : PurchaseSelection -} - -/** - * Pure entitlement resolution, kept out of the BillingClient so it can be tested. - * - * It encodes two rules that must not regress: - * - a PURCHASED purchase always wins over a PENDING one, so a paid purchase is - * never masked and is always acknowledged; - * - TRIAL and ACTIVE can only be told apart once the offer's pricing phases are - * known, so [statusFor] returns null while they are missing and the caller - * keeps the last known status and recomputes when the details arrive. - */ -object SubscriptionResolver { - - fun select(purchases: List): PurchaseSelection { - val purchased = purchases.firstOrNull { it.purchased } - if (purchased != null) { - return PurchaseSelection.Paid(purchased, purchases.firstOrNull { it.pending }) - } - val pending = purchases.firstOrNull { it.pending } - return if (pending != null) PurchaseSelection.Deferred(pending) else PurchaseSelection.None - } - - /** - * TRIAL, ACTIVE or CANCELED for a paid purchase, or null when [plan] is not - * loaded yet. Without the pricing phases a trial cannot be distinguished from - * a paid subscription, so the caller must wait rather than guess ACTIVE. - */ - fun statusFor( - purchase: PurchaseView, - plan: SubscriptionPlan?, - nowMillis: Long = System.currentTimeMillis(), - ): SubscriptionStatus? { - if (plan == null) return null - val trialEnd = plan.trialEndMillis(purchase.purchaseTimeMillis) - return when { - trialEnd != null && nowMillis < trialEnd -> SubscriptionStatus.TRIAL - !purchase.autoRenewing -> SubscriptionStatus.CANCELED - else -> SubscriptionStatus.ACTIVE - } - } -} diff --git a/androidApp/src/main/res/values-de/strings.xml b/androidApp/src/main/res/values-de/strings.xml index ac17ec7..12f7bf8 100644 --- a/androidApp/src/main/res/values-de/strings.xml +++ b/androidApp/src/main/res/values-de/strings.xml @@ -5,20 +5,4 @@ Downloads Lädt weiter, wenn du die App verlässt. - - Keine Verbindung zu Google Play. Es wird erneut versucht. - Verbindung zu Google Play nicht möglich. - Die Verbindung zu Google Play läuft noch. Versuch es gleich erneut. - Das Abo ist in Google Play nicht konfiguriert. - Das Abo hat keine aktiven Angebote. - Das Abo ist noch nicht verfügbar. - Der Kauf konnte nicht bestätigt werden. Es wird erneut versucht. - Keine Verbindung. Prüfe dein Netzwerk und versuch es erneut. - Google Play ist gerade nicht verfügbar. - Die Verbindung zu Google Play wurde unterbrochen. - Käufe sind auf diesem Gerät oder Konto nicht verfügbar. - Das Abo ist in diesem Land oder Konto nicht verfügbar. - Fehler in der Kaufkonfiguration. Prüfe die Play Console. - Du hast dieses Abo bereits. - Der Vorgang mit Google Play konnte nicht abgeschlossen werden. diff --git a/androidApp/src/main/res/values-es/strings.xml b/androidApp/src/main/res/values-es/strings.xml index 79a66f3..3f80e2d 100644 --- a/androidApp/src/main/res/values-es/strings.xml +++ b/androidApp/src/main/res/values-es/strings.xml @@ -5,20 +5,4 @@ Descargas Mantiene las descargas aunque salgas de la app. - - Sin conexión con Google Play. Se reintentará. - No se pudo conectar con Google Play. - Aún conectando con Google Play. Inténtalo en un momento. - La suscripción no está configurada en Google Play. - La suscripción no tiene ofertas activas. - La suscripción aún no está disponible. - No se pudo confirmar la compra. Se reintentará. - Sin conexión. Comprueba tu red e inténtalo de nuevo. - Google Play no está disponible ahora mismo. - Se perdió la conexión con Google Play. - Las compras no están disponibles en este dispositivo o cuenta. - La suscripción no está disponible en este país o cuenta. - Error de configuración de la compra. Revisa Play Console. - Ya tienes esta suscripción. - No se pudo completar la operación con Google Play. diff --git a/androidApp/src/main/res/values-fr/strings.xml b/androidApp/src/main/res/values-fr/strings.xml index cea2eaa..4fe09dd 100644 --- a/androidApp/src/main/res/values-fr/strings.xml +++ b/androidApp/src/main/res/values-fr/strings.xml @@ -5,20 +5,4 @@ Téléchargements Continue le téléchargement quand vous quittez l\'app. - - Pas de connexion à Google Play. Une nouvelle tentative suivra. - Impossible de se connecter à Google Play. - Connexion à Google Play en cours. Réessaie dans un instant. - L\'abonnement n\'est pas configuré dans Google Play. - L\'abonnement n\'a pas d\'offres actives. - L\'abonnement n\'est pas encore disponible. - L\'achat n\'a pas pu être confirmé. Une nouvelle tentative suivra. - Pas de connexion. Vérifie ton réseau et réessaie. - Google Play n\'est pas disponible pour le moment. - La connexion à Google Play a été perdue. - Les achats ne sont pas disponibles sur cet appareil ou ce compte. - L\'abonnement n\'est pas disponible dans ce pays ou ce compte. - Erreur de configuration de l\'achat. Vérifie la Play Console. - Tu as déjà cet abonnement. - L\'opération avec Google Play n\'a pas pu aboutir. diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index 7296a0e..3ca4b41 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -3,9 +3,9 @@ @@ -15,20 +15,4 @@ Downloads Keeps downloads going when you leave the app. - - No connection to Google Play. It will retry. - Could not connect to Google Play. - Still connecting to Google Play. Try again in a moment. - The subscription is not configured in Google Play. - The subscription has no active offers. - The subscription is not available yet. - The purchase could not be confirmed. It will retry. - No connection. Check your network and try again. - Google Play is not available right now. - The connection to Google Play was lost. - Purchases are not available on this device or account. - The subscription is not available in this country or account. - Purchase configuration error. Check Play Console. - You already have this subscription. - The operation with Google Play could not be completed. diff --git a/androidApp/src/playstore/java/com/glosso/app/Flavor.kt b/androidApp/src/playstore/java/com/glosso/app/Flavor.kt new file mode 100644 index 0000000..31b267e --- /dev/null +++ b/androidApp/src/playstore/java/com/glosso/app/Flavor.kt @@ -0,0 +1,9 @@ +package com.glosso.app + +/** What only the Google Play build wires: Sign in with Google for "Change phone". */ +internal object Flavor { + fun install() { + GoogleSignIn.serverClientId = BuildConfig.GOOGLE_SERVER_CLIENT_ID + accountSignIn = GoogleSignIn + } +} diff --git a/shared/src/androidMain/kotlin/com/glosso/app/AccountSignIn.android.kt b/androidApp/src/playstore/java/com/glosso/app/GoogleSignIn.kt similarity index 97% rename from shared/src/androidMain/kotlin/com/glosso/app/AccountSignIn.android.kt rename to androidApp/src/playstore/java/com/glosso/app/GoogleSignIn.kt index 4a98d2c..24855c5 100644 --- a/shared/src/androidMain/kotlin/com/glosso/app/AccountSignIn.android.kt +++ b/androidApp/src/playstore/java/com/glosso/app/GoogleSignIn.kt @@ -12,8 +12,6 @@ import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException -actual val accountSignIn: AccountSignIn get() = GoogleSignIn - /** * Sign in with Google through Android Credential Manager: the system shows the * Google accounts already on the device and the learner picks one. No password diff --git a/androidApp/src/release/java/com/glosso/app/billing/FakeBilling.kt b/androidApp/src/release/java/com/glosso/app/billing/FakeBilling.kt deleted file mode 100644 index 5b517c4..0000000 --- a/androidApp/src/release/java/com/glosso/app/billing/FakeBilling.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.glosso.app.billing - -/** - * Release has no fake. The symbol exists only so [BillingProvider] compiles; it - * always returns null and never references a fake implementation. - */ -internal fun debugFakeBilling(): SubscriptionRepository? = null diff --git a/androidApp/src/test/java/com/glosso/app/AnalyticsTest.kt b/androidApp/src/test/java/com/glosso/app/AnalyticsTest.kt index bfb0e25..03d8fab 100644 --- a/androidApp/src/test/java/com/glosso/app/AnalyticsTest.kt +++ b/androidApp/src/test/java/com/glosso/app/AnalyticsTest.kt @@ -50,6 +50,24 @@ class AnalyticsTest { private fun names() = sent.flatten().map { it.name } + @Test + fun `a build that defaults statistics off sends nothing until the learner turns them on`() { + Analytics.resetForTest() + Analytics.scope = CoroutineScope(UnconfinedTestDispatcher()) + Analytics.sender = { batch -> sent.add(batch); 202 } + Analytics.defaultEnabled = false + Analytics.init(app) + assertFalse(Analytics.enabled.value) + Analytics.track("lesson_started") + Analytics.flush() + assertTrue(sent.isEmpty()) + + Analytics.setEnabled(app, true) + Analytics.track("lesson_started") + Analytics.flush() + assertEquals(listOf("lesson_started"), names()) + } + @Test fun `events wait on disk while offline and are sent once the server takes them`() { status = null diff --git a/androidApp/src/test/java/com/glosso/app/ContentGateTest.kt b/androidApp/src/test/java/com/glosso/app/ContentGateTest.kt deleted file mode 100644 index b64860b..0000000 --- a/androidApp/src/test/java/com/glosso/app/ContentGateTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.glosso.app - -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -/** - * The regression guard for the gate. Issue #42 shipped a CTA that opened a - * lesson without asking [com.glosso.app.billing.AccessController], so this test - * reads the production sources and fails if a content-opening site stops going - * through the one gate, if the entitlement starts being decided outside the - * billing package, or if the free frontier is re-derived on a screen. - * - * It is a source audit, like [LicensesTest]: the point is to catch the next - * screen that "just opens" content, not to exercise the composables. - */ -class ContentGateTest { - - /** The app's production sources: the Android app and the shared (multiplatform) code. */ - private fun mainSourceDirs(): List { - var dir: File? = File(".").absoluteFile - while (dir != null) { - val app = File(dir, "androidApp/src/main/java/com/glosso/app") - val shared = File(dir, "shared/src/commonMain/kotlin/com/glosso/app") - if (app.isDirectory && shared.isDirectory) return listOf(app, shared) - dir = dir.parentFile - } - error("app sources not found from ${File(".").absolutePath}") - } - - private fun kotlinFiles(): List = - mainSourceDirs().flatMap { root -> root.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() } - - private fun File.isBilling(): Boolean = - path.replace('\\', '/').contains("/billing/") - - /** - * The calls that actually start content for the learner. The `(? contentOpeners.any { it.containsMatchIn(file.readText()) } } - .filterNot { it.readText().contains("AccessController") } - - assertTrue( - "content is opened without AccessController in: " + - offenders.joinToString { it.name }, - offenders.isEmpty(), - ) - } - - @Test - fun `the Pro surfaces each name the gate they use`() { - // Pins the four surfaces F7 protects plus the shell, so removing the gate - // from one of them is a red build, not a silent regression. - val expectations = mapOf( - "LessonsScreen.kt" to "AccessController", - "StoriesScreen.kt" to "AccessController", - "ExercisesScreen.kt" to "AccessController", - "GlossoApp.kt" to "AccessController", - "PracticeViewModel.kt" to "AccessController", - "YouScreen.kt" to "ProFeature.PHONETIC_PROFILE", - // Today shows the sounds gate as a quiet locked row (no second gold - // button), so the marker is the short locked line it renders, not the - // full ProLockedCard. - "TodayScreen.kt" to "today_sounds_locked", - ) - expectations.forEach { (name, marker) -> - val file = kotlinFiles().firstOrNull { it.name == name } - ?: error("expected production file $name") - assertTrue("$name must gate through $marker", file.readText().contains(marker)) - } - } - - @Test - fun `the entitlement predicate is only read inside billing`() { - // `AccessPolicy.isEntitled` is the gate's own function and is allowed; a - // raw `.isEntitled` on a screen is not. - val directUse = Regex("""(? respond( - """{"schema":1,"mode":"BETA","beta":{"active":true,"endDate":"2026-12-31T23:59:59Z"},"limits":null,"pricing":null,"flags":{"pro":false}}""", + """{"schema":1,"flags":{"accounts":true},"client":{"recommendedVersion":2219}}""", headers = headersOf(HttpHeaders.ContentType, "application/json"), ) "/v1/me" -> respond( - """{"plan":"FREE","status":"NONE","betaFounder":true,"betaFounderBenefit":"Ventaja Beta Founder"}""", + """{"linkedAccounts":["google"]}""", headers = headersOf(HttpHeaders.ContentType, "application/json"), ) @@ -55,10 +55,9 @@ class GlossoApiProductTest { assertEquals("device-123", configDevice) assertEquals("device-123", meDevice) - assertEquals("BETA", config.mode) - assertEquals("2026-12-31T23:59:59Z", config.beta.endDate) - assertEquals("FREE", me.plan) - assertEquals(true, me.betaFounder) + assertEquals(true, config.flag("accounts")) + assertEquals(2219, config.client.recommendedVersion) + assertEquals(listOf("google"), me.linkedAccounts) } @Test diff --git a/androidApp/src/test/java/com/glosso/app/api/ProductConfigTest.kt b/androidApp/src/test/java/com/glosso/app/api/ProductConfigTest.kt index 141def3..f51ec74 100644 --- a/androidApp/src/test/java/com/glosso/app/api/ProductConfigTest.kt +++ b/androidApp/src/test/java/com/glosso/app/api/ProductConfigTest.kt @@ -1,208 +1,77 @@ package com.glosso.app.api -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.ProductPlan -import com.glosso.app.product.formatBetaEndDate -import java.util.Locale -import java.util.TimeZone +import com.glosso.app.product.VersionNotice import kotlinx.serialization.json.Json -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test /** - * The frozen contract from docs/PRODUCT-CONFIG-CONTRACT.md, decoded into the - * client model. The server owns every value here; these tests pin that the app - * reads them as published and derives the same decisions the contract promises. + * `GET /v1/config` and `GET /v1/me` decoded into the client model. The client + * reads only the flags, the version policy and the linked accounts; the keys a + * server still sends for older app versions (the beta window, plans, prices, + * founder fields) must decode without breaking anything. */ class ProductConfigTest { private val json = Json { ignoreUnknownKeys = true } - private val contract = """ + private val current = """ { "schema": 1, "mode": "BETA", - "beta": { - "active": true, - "endDate": "2026-12-31T23:59:59Z", - "founderEnabled": true, - "founderBenefit": { "kind": "discount_percent", "value": null, "until": null, "label": "Ventaja Beta Founder" } - }, - "plans": { - "FREE": { "pronunciation_analysis": "LIMITED", "exercises": "LIMITED", "analytics": "BASIC", "personalization": "NONE" }, - "PRO": { "pronunciation_analysis": "UNLIMITED", "exercises": "EXTENDED", "analytics": "ADVANCED", "personalization": "NONE" }, - "PERSONALIZED": { "pronunciation_analysis": "UNLIMITED", "exercises": "UNLIMITED", "analytics": "ADVANCED", "personalization": "FULL" } - }, - "limits": { "FREE": { "pronunciation_analysis_per_day": 20, "history_days": 30 } }, - "pricing": { - "PRO": { "monthly": null, "yearly": null, "currency": null }, - "PERSONALIZED": { "monthly": null, "yearly": null, "currency": null } - }, - "flags": { "beta_banner": true, "pro": false, "personalized": false, "accounts": false }, + "beta": { "active": true }, + "flags": { "accounts": true }, "client": { "minimumSupportedVersion": 2210, "recommendedVersion": 2219, "forceUpdate": false } } """.trimIndent() - private lateinit var previousLocale: Locale - private lateinit var previousZone: TimeZone - - @Before - fun setUp() { - previousLocale = Locale.getDefault() - previousZone = TimeZone.getDefault() - Locale.setDefault(Locale.US) - // A timezone far ahead of UTC must not push 23:59:59Z into the next day. - TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati")) - } - - @After - fun tearDown() { - Locale.setDefault(previousLocale) - TimeZone.setDefault(previousZone) - } - - private fun config() = json.decodeFromString(contract) + private fun config() = json.decodeFromString(current) @Test - fun `the contract decodes field by field`() { + fun `the config decodes the flags and the version policy`() { val config = config() assertEquals(1, config.schema) - assertTrue(config.isBeta) - assertTrue(config.beta.active) - assertEquals("2026-12-31T23:59:59Z", config.beta.endDate) - assertTrue(config.beta.founderEnabled) - assertEquals(setOf("FREE", "PRO", "PERSONALIZED"), config.plans.keys) - assertEquals("FULL", config.plans.getValue("PERSONALIZED").personalization) - assertEquals(20, config.limits?.getValue("FREE")?.pronunciationAnalysisPerDay) - assertEquals(30, config.limits?.getValue("FREE")?.historyDays) - assertNull(config.pricingOf("PRO")?.monthly) - assertFalse(config.flag("pro")) + assertTrue(config.flag("accounts")) assertFalse("a missing flag is false", config.flag("does_not_exist")) assertEquals(2210, config.client.minimumSupportedVersion) assertEquals(2219, config.client.recommendedVersion) } - @Test - fun `the beta is free and sells nothing while pricing is null`() { - val access = AccessPolicy.productAccess(config(), AccountState(plan = "FREE")) - assertTrue(access.betaFree) - assertTrue("the whole course is usable in the open beta", access.entitled) - assertFalse("a null price is never for sale", access.sellingEnabled) - assertEquals(ProductPlan.FREE, access.plan) - } - - @Test - fun `after the beta a published price enables selling and the server plan authorises`() { - val paid = ProductConfig( - mode = ProductConfig.MODE_FREE, - flags = mapOf("pro" to true), - pricing = mapOf("PRO" to PlanPricing(monthly = 4.99, currency = "EUR")), - ) - val access = AccessPolicy.productAccess(paid, AccountState(plan = "PRO", betaFounder = true)) - assertFalse(access.betaFree) - assertTrue(access.sellingEnabled) - assertEquals(ProductPlan.PRO, access.plan) - assertTrue(access.entitled) - } - - @Test - fun `no price means no selling even out of beta`() { - val noPrice = ProductConfig( - mode = ProductConfig.MODE_FREE, - flags = mapOf("pro" to true), - pricing = mapOf("PRO" to PlanPricing(monthly = null, yearly = null, currency = null)), - ) - assertFalse(AccessPolicy.productAccess(noPrice, AccountState(plan = "FREE")).sellingEnabled) - } - - @Test - fun `an unknown plan code never becomes paid`() { - assertEquals(ProductPlan.FREE, ProductPlan.fromWire(null)) - assertEquals(ProductPlan.FREE, ProductPlan.fromWire("bogus")) - assertEquals(ProductPlan.PERSONALIZED, ProductPlan.fromWire("personalized")) - } - @Test fun `the version notice warns without ever blocking`() { val config = config() - val current = AccessPolicy.versionNotice(config, currentVersion = 2219) - assertFalse(current.visible) + assertFalse(VersionNotice.from(config, currentVersion = 2219).visible) - val outdated = AccessPolicy.versionNotice(config, currentVersion = 2210) + val outdated = VersionNotice.from(config, currentVersion = 2210) assertTrue(outdated.outdated) assertFalse("2210 is exactly the minimum, not below it", outdated.belowMinimum) - val below = AccessPolicy.versionNotice(config, currentVersion = 2000) + val below = VersionNotice.from(config, currentVersion = 2000) assertTrue(below.belowMinimum) assertTrue(below.visible) } @Test - fun `the end date is formatted from the server value and never invented`() { - assertEquals("December 31, 2026", formatBetaEndDate("2026-12-31T23:59:59Z")) - assertNull(formatBetaEndDate(null)) - assertNull(formatBetaEndDate("")) - assertNull("garbage is not a date", formatBetaEndDate("not-a-date")) - } - - @Test - fun `the fallback keeps the beta open when the server never answers`() { - val access = AccessPolicy.productAccess(ProductConfig.BetaFallback, null) - assertTrue(access.betaFree) - assertTrue(access.entitled) - assertFalse(access.sellingEnabled) - assertNull(formatBetaEndDate(ProductConfig.BetaFallback.beta.endDate)) + fun `an empty config shows no version notice`() { + assertFalse(VersionNotice.from(ProductConfig(), currentVersion = 1).visible) } - /** - * The literal body `GET /v1/config` serves today (PR #164). It differs from - * [contract] in the two fields that are still undecided: `limits` and - * `pricing` are JSON `null`, not objects. The mock used by the E2E never - * produced this shape, so the app decoded the mock fine and crashed on the - * real server. The fixture is the real bytes. - */ - private val serverConfigBody: String by lazy { fixture("server-product-config.json") } - - /** The literal body `GET /v1/me` serves today: the benefit travels as a plain string. */ - private val serverMeBody: String by lazy { fixture("server-me.json") } - @Test - fun `the real server config body with null limits and pricing decodes whole`() { - val config = json.decodeFromString(serverConfigBody) - assertEquals(1, config.schema) - assertTrue(config.isBeta) - assertTrue(config.beta.active) - assertEquals("2026-12-31T23:59:59Z", config.beta.endDate) - assertEquals("Ventaja Beta Founder", config.beta.founderBenefit?.label) - assertEquals(setOf("FREE", "PRO", "PERSONALIZED"), config.plans.keys) - assertNull("limits is null while undecided", config.limits) - assertNull("pricing is null while undecided", config.pricing) - assertNull(config.pricingOf("PRO")) + fun `a legacy config body with plans and null pricing still decodes`() { + val config = json.decodeFromString(fixture("legacy-product-config.json")) assertTrue(config.flag("beta_banner")) - assertFalse("a missing flag is still false", config.flag("pro")) + assertFalse(config.flag("pro")) assertEquals(2210, config.client.minimumSupportedVersion) } @Test - fun `the real server me body decodes betaFounderBenefit as a string`() { - val account = json.decodeFromString(serverMeBody) - assertEquals("FREE", account.plan) - assertTrue(account.betaFounder) - assertEquals("Ventaja Beta Founder", account.betaFounderBenefit) - } - - @Test - fun `the real config body keeps the beta free and sells nothing`() { - val account = json.decodeFromString(serverMeBody) - val access = AccessPolicy.productAccess(json.decodeFromString(serverConfigBody), account) - assertTrue(access.betaFree) - assertTrue("the whole course is usable", access.entitled) - assertFalse("null pricing never sells", access.sellingEnabled) + fun `a legacy me body with plan and founder fields still decodes`() { + val account = json.decodeFromString(fixture("legacy-me.json")) + assertNull(account.linkedAccounts) + assertEquals(listOf("google"), json.decodeFromString("""{"linkedAccounts":["google"]}""").linkedAccounts) } private fun fixture(name: String): String = diff --git a/androidApp/src/test/java/com/glosso/app/billing/AccessControllerTest.kt b/androidApp/src/test/java/com/glosso/app/billing/AccessControllerTest.kt deleted file mode 100644 index 98971ce..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/AccessControllerTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.engine.model.DrillModel -import com.glosso.engine.model.ExerciseModel -import com.glosso.engine.model.LessonModel -import com.glosso.engine.model.StoryModel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class AccessControllerTest { - - private fun lesson( - id: String, - level: String = "A1", - theme: String = "family", - ) = LessonModel(id = id, level = level, theme = theme, title = id) - - private fun story(id: String, level: String = "A1") = - StoryModel(id = id, level = level, title = id) - - private fun exercise(id: String, level: String = "A1") = - ExerciseModel(id = id, level = level, type = "multiple-choice", prompt = id) - - private fun drill(id: String, level: String = "A1") = - DrillModel(id = id, level = level, target = "θ", text = id) - - private val a1 = lesson("family.1") - private val a2 = lesson("city.2", level = "A2") - - private fun controller( - status: SubscriptionStatus, - inProgressLessonId: String? = null, - ) = AccessController(status, inProgressLessonId) - - @Test - fun `the controller exposes the entitlement and the free level once`() { - val free = controller(SubscriptionStatus.NONE) - assertFalse(free.entitled) - assertEquals(LessonAccess.FREE, free.accessOf(a1)) - assertEquals(LessonAccess.PRO, free.accessOf(a2)) - } - - @Test - fun `a free learner opens and downloads A1, but not A2`() { - val free = controller(SubscriptionStatus.NONE) - assertFalse(free.isLocked(a1)) - assertTrue(free.canDownload(a1)) - assertTrue(free.isLocked(a2)) - assertFalse(free.canDownload(a2)) - } - - @Test - fun `an entitled learner opens and downloads everything`() { - val pro = controller(SubscriptionStatus.ACTIVE) - assertTrue(pro.entitled) - assertFalse(pro.isLocked(a2)) - assertTrue(pro.canDownload(a2)) - } - - @Test - fun `a paid lesson in progress is not locked after the entitlement lapses`() { - val lapsed = controller(SubscriptionStatus.NONE, inProgressLessonId = a2.id) - assertFalse(lapsed.isLocked(a2)) - // Being continuable does not turn it into a free download. - assertFalse(lapsed.canDownload(a2)) - } - - @Test - fun `pending does not unlock Pro content`() { - val pending = controller(SubscriptionStatus.PENDING) - assertFalse(pending.entitled) - assertTrue(pending.isLocked(a2)) - } - - @Test - fun `every content kind is gated by its own level`() { - // The gate reads ContentMeta, not the screen: a story, an exercise and a - // drill are judged exactly like a lesson, so no surface can invent a - // frontier of its own. - val free = controller(SubscriptionStatus.NONE) - assertFalse(free.isLocked(story("s1"))) - assertTrue(free.isLocked(story("s2", level = "A2"))) - assertFalse(free.isLocked(exercise("e1"))) - assertTrue(free.isLocked(exercise("e2", level = "B1"))) - assertFalse(free.isLocked(drill("d1"))) - assertTrue(free.isLocked(drill("d2", level = "A2"))) - assertEquals(LessonAccess.FREE, free.accessOf(story("s1"))) - assertEquals(LessonAccess.PRO, free.accessOf(drill("d2", level = "A2"))) - - val pro = controller(SubscriptionStatus.ACTIVE) - assertFalse(pro.isLocked(story("s2", level = "A2"))) - assertFalse(pro.isLocked(exercise("e2", level = "B1"))) - assertFalse(pro.isLocked(drill("d2", level = "A2"))) - } - - @Test - fun `the Pro features are locked for a free learner and open for a Pro one`() { - val free = controller(SubscriptionStatus.NONE) - assertTrue(free.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - assertTrue(free.isLocked(ProFeature.PHONETIC_PROFILE)) - // A pending payment is not entitlement either. - val pending = controller(SubscriptionStatus.PENDING) - assertTrue(pending.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - - val pro = controller(SubscriptionStatus.TRIAL) - assertFalse(pro.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - assertFalse(pro.isLocked(ProFeature.PHONETIC_PROFILE)) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/AccessPolicyTest.kt b/androidApp/src/test/java/com/glosso/app/billing/AccessPolicyTest.kt deleted file mode 100644 index 9f481ea..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/AccessPolicyTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.engine.model.LessonModel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class AccessPolicyTest { - - private fun lesson( - id: String, - level: String = "A1", - theme: String = "city", - ) = LessonModel(id = id, level = level, theme = theme, title = id) - - // The catalog arrives in the order the server happens to return: the teaching - // order is not its order, so only the published order can pick the first one. - private val catalog = listOf( - lesson("city.1", theme = "city"), - lesson("family.1", theme = "family"), - lesson("food.1", theme = "food"), - lesson("a2.city.1", level = "A2", theme = "city"), - lesson("b1.city.1", level = "B1", theme = "city"), - lesson("b2.city.1", level = "B2", theme = "city"), - ) - - // What the manifest publishes: the pedagogical order, not alphabetical. - private val themeOrder = listOf("family", "travel", "city", "food") - - private val free = catalog.first { it.id == "family.1" } - private val a2 = catalog.first { it.id == "a2.city.1" } - - @Test - fun `the whole of A1 is free and every later level is Pro`() { - for (item in catalog) { - if (item.level == "A1") { - assertTrue("A1 should be free: ${item.id}", AccessPolicy.isLessonFree(item)) - } else { - assertFalse("only A1 is free: ${item.id}", AccessPolicy.isLessonFree(item)) - } - } - // A1/EXPIRED and PENDING both mean "no entitlement": the A1 level only. - assertFalse(AccessPolicy.requiresPaywall(free, SubscriptionStatus.NONE)) - for (level in listOf("A2", "B1", "B2")) { - val paid = catalog.first { it.level == level } - assertTrue( - "the wall is the CEFR level, not a block: $level", - AccessPolicy.requiresPaywall(paid, SubscriptionStatus.NONE), - ) - } - } - - @Test - fun `the block order follows the manifest and appends unknown themes`() { - val withExtra = catalog + lesson("airport.1", theme = "airport") - assertEquals( - listOf("family", "city", "food", "airport"), - AccessPolicy.blockOrder(withExtra, themeOrder), - ) - } - - @Test - fun `the first lesson is the first theme in teaching order`() { - assertEquals("family.1", AccessPolicy.firstLesson(catalog, themeOrder)?.id) - assertNull(AccessPolicy.firstLesson(emptyList(), themeOrder)) - } - - @Test - fun `inside a block a dialogue comes before a story and an article, like the list`() { - val block = listOf( - LessonModel(id = "family.a", theme = "family", title = "article", articleId = "x"), - LessonModel(id = "family.b", theme = "family", title = "story", storyId = "x"), - LessonModel(id = "family.c", theme = "family", title = "dialogue", dialogueId = "x"), - ) - - assertEquals( - listOf("family.c", "family.b", "family.a"), - AccessPolicy.teachingOrder(block, themeOrder).map { it.id }, - ) - assertEquals("family.c", AccessPolicy.firstLesson(block, themeOrder)?.id) - assertEquals("family.b", AccessPolicy.nextLesson(block, themeOrder, listOf("family.c"))?.id) - } - - @Test - fun `a paid lesson shows the paywall before starting, never mid-lesson`() { - assertFalse(AccessPolicy.requiresPaywall(free, SubscriptionStatus.NONE)) - assertTrue(AccessPolicy.requiresPaywall(a2, SubscriptionStatus.NONE)) - } - - @Test - fun `a lesson already in progress can always be finished`() { - assertTrue( - AccessPolicy.isLessonUnlocked(a2, SubscriptionStatus.NONE, inProgressLessonId = a2.id), - ) - assertFalse( - AccessPolicy.requiresPaywall(a2, SubscriptionStatus.NONE, inProgressLessonId = a2.id), - ) - } - - @Test - fun `a lesson started free is finished free under every status`() { - // "Empezada gratis": a free lesson is never gated, whatever the entitlement - // does later, so the learner who began it finishes it without paying. - for (status in SubscriptionStatus.entries) { - assertFalse( - "free lesson blocked for $status", - AccessPolicy.requiresPaywall(free, status, inProgressLessonId = free.id), - ) - } - } - - @Test - fun `entitlement unlocks the whole catalog`() { - val entitled = listOf( - SubscriptionStatus.TRIAL, - SubscriptionStatus.ACTIVE, - SubscriptionStatus.GRACE, - SubscriptionStatus.CANCELED, - ) - for (status in entitled) { - assertTrue(AccessPolicy.isEntitled(status)) - for (item in catalog) { - assertFalse(AccessPolicy.requiresPaywall(item, status)) - } - } - } - - @Test - fun `pending is not entitled`() { - assertFalse(AccessPolicy.isEntitled(SubscriptionStatus.PENDING)) - assertTrue(AccessPolicy.requiresPaywall(a2, SubscriptionStatus.PENDING)) - } - - @Test - fun `the level frontier reads the same for every content kind`() { - assertTrue(AccessPolicy.isLevelFree("A1")) - assertFalse(AccessPolicy.isLevelFree("A2")) - assertFalse(AccessPolicy.isLevelFree("B1")) - assertFalse(AccessPolicy.isLevelFree("B2")) - assertEquals(LessonAccess.FREE, AccessPolicy.accessOf("A1")) - assertEquals(LessonAccess.PRO, AccessPolicy.accessOf("B2")) - } - - @Test - fun `the Pro features follow the entitlement`() { - assertFalse( - AccessPolicy.isFeatureUnlocked(SubscriptionStatus.NONE, ProFeature.PERSONALIZED_PRACTICE), - ) - assertFalse( - AccessPolicy.isFeatureUnlocked(SubscriptionStatus.PENDING, ProFeature.PHONETIC_PROFILE), - ) - assertTrue( - AccessPolicy.isFeatureUnlocked(SubscriptionStatus.CANCELED, ProFeature.PERSONALIZED_PRACTICE), - ) - assertTrue( - AccessPolicy.isFeatureUnlocked(SubscriptionStatus.GRACE, ProFeature.PHONETIC_PROFILE), - ) - } - - @Test - fun `offline downloads mirror the level frontier`() { - assertTrue(AccessPolicy.canDownloadOffline(free, SubscriptionStatus.NONE)) - assertFalse(AccessPolicy.canDownloadOffline(a2, SubscriptionStatus.NONE)) - assertTrue(AccessPolicy.canDownloadOffline(a2, SubscriptionStatus.ACTIVE)) - } - - // ------------------------------------------------------------ access matrix - - /** - * The full matrix: every subscription status crossed with free (A1) content, - * Pro (A2) content and offline download. Every member is asserted so a new one - * cannot slip through untested. - */ - @Test - fun `access matrix over every subscription status`() { - data class Row( - val status: SubscriptionStatus, - val pro: Boolean, - val opensFree: Boolean, - val opensPro: Boolean, - val downloadsFree: Boolean, - val downloadsPro: Boolean, - ) - - val rows = listOf( - // FREE and EXPIRED both mean "no entitlement": A1 only. - Row(SubscriptionStatus.NONE, false, true, false, true, false), - Row(SubscriptionStatus.TRIAL, true, true, true, true, true), - Row(SubscriptionStatus.ACTIVE, true, true, true, true, true), - Row(SubscriptionStatus.GRACE, true, true, true, true, true), - Row(SubscriptionStatus.CANCELED, true, true, true, true, true), - Row(SubscriptionStatus.PENDING, false, true, false, true, false), - ) - - for (row in rows) { - assertEquals("isEntitled for ${row.status}", row.pro, AccessPolicy.isEntitled(row.status)) - assertEquals( - "open A1 for ${row.status}", - row.opensFree, - !AccessPolicy.requiresPaywall(free, row.status), - ) - assertEquals( - "open A2 for ${row.status}", - row.opensPro, - !AccessPolicy.requiresPaywall(a2, row.status), - ) - assertEquals( - "download A1 for ${row.status}", - row.downloadsFree, - AccessPolicy.canDownloadOffline(free, row.status), - ) - assertEquals( - "download A2 for ${row.status}", - row.downloadsPro, - AccessPolicy.canDownloadOffline(a2, row.status), - ) - } - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/BillingCopyTest.kt b/androidApp/src/test/java/com/glosso/app/billing/BillingCopyTest.kt deleted file mode 100644 index 0960988..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/BillingCopyTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.R -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -/** - * The paywall copy must never carry a price or a trial length: both come from - * Play's [com.android.billingclient.api.ProductDetails] at runtime. A `paywall_*` - * / `billing_*` string that hardcodes an amount, a duration or a currency symbol - * fails here. The only numbers allowed are content references like "A1" (a digit - * directly after a letter); everything else must arrive as a format argument. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class BillingCopyTest { - - private val formatArg = Regex("%[0-9]+\\\$[a-zA-Z]") - private val literalNumber = Regex("(? - val value = context.getString(field.getInt(null)).replace(formatArg, "") - val bad = literalNumber.containsMatchIn(value) || currency.containsMatchIn(value) - if (bad) "${field.name} = $value" else null - } - - assertTrue( - "billing copy must come from Play, found literals: $offenders", - offenders.isEmpty(), - ) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsParityTest.kt b/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsParityTest.kt deleted file mode 100644 index b532b5b..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsParityTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.glosso.app.billing - -import java.time.Instant -import java.time.Period -import java.time.ZoneOffset -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * The shared period parser replaced `java.time.Period`: every period Play can - * return, and the odd ones, must read and add up exactly as java.time did. - */ -class BillingPeriodsParityTest { - - private val inputs = listOf( - "P1M", "P3M", "P6M", "P1Y", "P2Y", "P7D", "P14D", "P3D", "P1D", "P1W", "P2W", "P4W", "P30D", - "P1Y2M", "P1Y2M3D", "P1Y2M3W4D", "P0D", "P0M", "P-1M", "-P1M", "-P-1M", "P+1M", "p1m", "P1m", - "P12M", "P13M", "P31D", "P2147483647D", "P-2147483648D", "-P-2147483648D", "P306783379W", - "P306783378W1D", "P", "PT1H", "P1", "1M", "P1.5M", "", " P1M", "P1MX", "P1M2Y", "P99999999999D", - ) - - private fun javaParts(iso: String): Triple? = - runCatching { Period.parse(iso) }.getOrNull()?.let { Triple(it.years, it.months, it.days) } - - @Test - fun `parses like java time`() { - for (iso in inputs) { - val shared = BillingPeriods.parse(iso)?.let { Triple(it.years, it.months, it.days) } - val expected = if (iso.isBlank()) null else javaParts(iso) - assertEquals(iso, expected, shared) - } - } - - private fun javaEnd(start: Long, iso: String): Long? { - val period = runCatching { Period.parse(iso) }.getOrNull() ?: return null - return runCatching { - Instant.ofEpochMilli(start).atZone(ZoneOffset.UTC).plus(period).toInstant().toEpochMilli() - }.getOrNull() - } - - @Test - fun `adds a trial like java time`() { - val starts = listOf( - 0L, 1_759_276_800_000L, 1_706_659_200_000L, 1_706_745_599_999L, 1_709_164_800_000L, - 1_735_603_200_000L, 1_738_281_600_000L, 951_782_400_000L, -86_400_000L, -1L, 4_102_444_800_000L, - ) - val periods = listOf("P1M", "P3M", "P1Y", "P7D", "P1W", "P1Y1M", "P1M30D", "P-1M", "-P1Y", "P12M", "P0D", "P4Y") - for (start in starts) for (iso in periods) { - assertEquals("$iso from $start", javaEnd(start, iso), BillingPeriods.endMillis(start, iso)) - } - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsTest.kt b/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsTest.kt deleted file mode 100644 index 8041221..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/BillingPeriodsTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.glosso.app.billing - -import java.time.Instant -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class BillingPeriodsTest { - - @Test - fun `parses Play periods into a count and a unit`() { - assertEquals(PeriodParts(1, PeriodUnit.MONTH), BillingPeriods.parts("P1M")) - assertEquals(PeriodParts(3, PeriodUnit.MONTH), BillingPeriods.parts("P3M")) - assertEquals(PeriodParts(1, PeriodUnit.YEAR), BillingPeriods.parts("P1Y")) - assertEquals(PeriodParts(1, PeriodUnit.WEEK), BillingPeriods.parts("P7D")) - assertEquals(PeriodParts(2, PeriodUnit.WEEK), BillingPeriods.parts("P14D")) - assertEquals(PeriodParts(1, PeriodUnit.DAY), BillingPeriods.parts("P1D")) - assertNull(BillingPeriods.parts(null)) - assertNull(BillingPeriods.parts("")) - assertNull(BillingPeriods.parts("no-es-un-periodo")) - } - - @Test - fun `trial end is purchase time plus the trial period`() { - val end = BillingPeriods.endMillis(0L, "P1M") - assertEquals(Instant.parse("1970-02-01T00:00:00Z").toEpochMilli(), end) - } - - @Test - fun `no trial period means no end`() { - assertNull(BillingPeriods.endMillis(0L, null)) - assertNull(BillingPeriods.endMillis(0L, "not-a-period")) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/BillingProviderTest.kt b/androidApp/src/test/java/com/glosso/app/billing/BillingProviderTest.kt deleted file mode 100644 index b3115d8..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/BillingProviderTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class BillingProviderTest { - - @Test - fun `release can never use the fake`() { - // The gate that matters: even if the fake flag were mistakenly left on, a - // non-debuggable build must fall through to Play. - assertFalse(BillingProvider.useFake(billingFake = true, debuggable = false)) - assertFalse(BillingProvider.useFake(billingFake = false, debuggable = false)) - } - - @Test - fun `debug uses the fake when the flag is set`() { - assertTrue(BillingProvider.useFake(billingFake = true, debuggable = true)) - } - - @Test - fun `debug can opt into the real client`() { - assertFalse(BillingProvider.useFake(billingFake = false, debuggable = true)) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/ProductAccessTest.kt b/androidApp/src/test/java/com/glosso/app/billing/ProductAccessTest.kt deleted file mode 100644 index 1421f72..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/ProductAccessTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.api.AccountState -import com.glosso.app.api.ProductConfig -import com.glosso.engine.model.LessonModel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The three plans and the open beta as the gate reads them. The product phase is - * the server's, so a beta device opens everything and a plan the server grants - * does too, while the default [ProductAccess.Unknown] leaves the old, - * status-only behaviour untouched. - */ -class ProductAccessTest { - - private fun lesson(id: String, level: String = "A2") = - LessonModel(id = id, level = level, theme = "city", title = id) - - @Test - fun `the open beta unlocks every level and feature for any user`() { - // Any user, whether account is null or explicitly plan=FREE and betaFounder=false - val nonFounderFreeUser = AccountState(plan = "FREE", betaFounder = false) - val access = AccessPolicy.productAccess(ProductConfig.BetaFallback, nonFounderFreeUser) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertTrue(access.betaFree) - assertTrue(access.entitled) - assertTrue(access.personalized) - assertEquals(ProductPlan.PERSONALIZED, access.effectivePlan) - assertTrue(controller.entitled) - assertFalse("A2 is open in the beta", controller.isLocked(lesson("a2", level = "A2"))) - assertFalse("B1 is open in the beta", controller.isLocked(lesson("b1", level = "B1"))) - assertFalse("B2 is open in the beta", controller.isLocked(lesson("b2", level = "B2"))) - assertTrue("A2 offline download is open", controller.canDownload(lesson("a2", level = "A2"))) - assertTrue("B2 offline download is open", controller.canDownload(lesson("b2", level = "B2"))) - assertFalse("phonetic profile unlocked in beta", controller.isLocked(ProFeature.PHONETIC_PROFILE)) - assertFalse("personalized practice unlocked in beta", controller.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - assertFalse( - "requiresPaywall is false during beta", - AccessPolicy.requiresPaywall(lesson("b2", level = "B2"), SubscriptionStatus.NONE, access = access), - ) - } - - @Test - fun `a server-granted plan opens the course without a local purchase`() { - val paid = ProductConfig(mode = ProductConfig.MODE_FREE) - val access = AccessPolicy.productAccess(paid, AccountState(plan = "PERSONALIZED")) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertTrue(access.entitled) - assertTrue(access.personalized) - assertEquals(ProductPlan.PERSONALIZED, access.effectivePlan) - assertFalse(controller.isLocked(lesson("a2"))) - assertFalse(controller.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - } - - @Test - fun `a server-granted PRO plan opens PRO content but not personalized tier`() { - val paid = ProductConfig(mode = ProductConfig.MODE_FREE) - val access = AccessPolicy.productAccess(paid, AccountState(plan = "PRO")) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertTrue(access.entitled) - assertFalse(access.personalized) - assertEquals(ProductPlan.PRO, access.effectivePlan) - assertFalse(controller.isLocked(lesson("a2"))) - assertFalse(controller.isLocked(ProFeature.PHONETIC_PROFILE)) - assertTrue(controller.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - } - - /** - * The server keeps a Beta Founder on plan FREE and marks `betaFounder` as an - * orthogonal status. The gate must grant the PRO content tier from that status - * alone as a lifetime entitlement grant, and must not hand out PERSONALIZED with it. - */ - @Test - fun `a Beta Founder reaches PRO content but not the personalized tier`() { - val afterBeta = ProductConfig(mode = ProductConfig.MODE_FREE) - val access = AccessPolicy.productAccess(afterBeta, AccountState(plan = "FREE", betaFounder = true)) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertFalse("the open beta is over", access.betaFree) - assertEquals("the server still reports FREE", ProductPlan.FREE, access.plan) - assertTrue("the founder opens the PRO content", access.entitled) - assertFalse("personalized tier is not unlocked by founder", access.personalized) - assertEquals(ProductPlan.PRO, access.effectivePlan) - assertEquals(EntitlementKind.LIFETIME, access.lifetimeGrant?.kind) - assertEquals(ProductPlan.PRO, access.lifetimeGrant?.plan) - - assertFalse("A2 opens for a founder", controller.isLocked(lesson("a2"))) - assertFalse("the phonetic profile is PRO analytics", controller.isLocked(ProFeature.PHONETIC_PROFILE)) - assertTrue( - "personalized practice is not included", - controller.isLocked(ProFeature.PERSONALIZED_PRACTICE), - ) - } - - @Test - fun `a Beta Founder who also subscribes to PERSONALIZED reaches the top tier and keeps founder status`() { - val afterBeta = ProductConfig(mode = ProductConfig.MODE_FREE) - val access = AccessPolicy.productAccess(afterBeta, AccountState(plan = "PERSONALIZED", betaFounder = true)) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertTrue(access.entitled) - assertTrue(access.personalized) - assertEquals(ProductPlan.PERSONALIZED, access.effectivePlan) - assertTrue("founder status is preserved orthogonally", access.betaFounder) - assertEquals(ProductPlan.PRO, access.lifetimeGrant?.plan) - assertFalse(controller.isLocked(lesson("a2"))) - assertFalse(controller.isLocked(ProFeature.PERSONALIZED_PRACTICE)) - } - - @Test - fun `a plain FREE learner without the founder status stays locked`() { - val afterBeta = ProductConfig(mode = ProductConfig.MODE_FREE) - val access = AccessPolicy.productAccess(afterBeta, AccountState(plan = "FREE")) - val controller = AccessController(SubscriptionStatus.NONE, productAccess = access) - - assertFalse(access.betaFree) - assertFalse(access.entitled) - assertTrue(controller.isLocked(lesson("a2"))) - } - - @Test - fun `without product access the old status-only frontier is unchanged`() { - val controller = AccessController(SubscriptionStatus.NONE) - assertFalse(controller.entitled) - assertTrue("A2 still needs Pro outside the beta", controller.isLocked(lesson("a2"))) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/ProductionEntitlementGuardTest.kt b/androidApp/src/test/java/com/glosso/app/billing/ProductionEntitlementGuardTest.kt deleted file mode 100644 index d32fddb..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/ProductionEntitlementGuardTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -/** - * Audits the entitlement boundary at the *source* level: the debug build may ship - * a fake that starts Glosso Pro unlocked, but no production source set (`main`, - * `release`, any future flavour) may reference the fake or add a runtime switch - * that grants Pro. - * - * This is one of three layers. `ReleaseBillingConfigTest` pins the compile-time - * guard at runtime, and `:androidApp:verifyReleaseApkEntitlement` / - * `:androidApp:verifyReleaseBundleEntitlement` inspect the packaged release APK - * and AAB. Sources, compiled flags and the binary are checked separately because - * a source-only audit can be bypassed by generated code. - * Like `ContentGateTest`, it reads the tree instead of exercising composables. - */ -class ProductionEntitlementGuardTest { - - private fun moduleDir(): File { - var dir: File? = File(".").absoluteFile - while (dir != null) { - if (File(dir, "src/main").isDirectory) return dir - if (File(dir, "androidApp/src/main").isDirectory) return File(dir, "androidApp") - dir = dir.parentFile - } - error("androidApp module dir not found from ${File(".").absolutePath}") - } - - // The shared UI (`:shared`: commonMain, androidMain, iosMain) ships in the app too. - private fun productionSourceSets(): List = - listOf(moduleDir(), File(moduleDir().canonicalFile.parentFile, "shared")).flatMap { module -> - File(module, "src").listFiles() - ?.filter { it.isDirectory } - ?.filterNot { it.name == "debug" } - ?.filterNot { - it.name.startsWith("test") || it.name.startsWith("androidTest") || it.name.endsWith("Test") - } - ?.sortedBy { it.path } - .orEmpty() - } - - private fun productionKotlinFiles(): List = - productionSourceSets().flatMap { set -> - set.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() - } - - @Test - fun `production source sets never reference the debug fake`() { - val offenders = productionKotlinFiles().filter { - it.readText().contains("FakeSubscriptionRepository") - } - assertTrue( - "the debug fake leaked into a production source set: " + - offenders.joinToString { it.path }, - offenders.isEmpty(), - ) - } - - @Test - fun `production source sets carry no runtime entitlement override`() { - // Tripwires for a "flip a parameter and get Pro" path. None exists today; - // if shared code ever grows one, this fails instead of shipping a release - // where changing a flag unlocks Pro. - val overrideTokens = listOf( - "unlockPro", "UnlockPro", "DEBUG_UNLOCK", "devPro", "DEV_PRO", - "forceEntitled", "forcePro", "grantPro", "entitlementOverride", - "proOverride", "bypassBilling", "BypassBilling", "fakeEntitlement", - ) - val offenders = productionKotlinFiles().flatMap { file -> - val text = file.readText() - overrideTokens.filter { text.contains(it) }.map { "${it} in ${file.name}" } - } - assertTrue( - "a runtime Pro override reached a production source set: " + offenders.joinToString(), - offenders.isEmpty(), - ) - } - - @Test - fun `the release seam stubs the fake hook to null`() { - // `debugFakeBilling` is the only seam main knows about; release must define - // it as an inert null stub so there is nothing to wire up. The fake - // implementation lives only in debug. - val releaseStub = File(moduleDir(), "src/release/java/com/glosso/app/billing/FakeBilling.kt") - assertTrue("release FakeBilling stub is missing", releaseStub.isFile) - assertTrue( - "release debugFakeBilling must return null", - releaseStub.readText().contains("= null"), - ) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionPlanTest.kt b/androidApp/src/test/java/com/glosso/app/billing/SubscriptionPlanTest.kt deleted file mode 100644 index 2f65f04..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionPlanTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class SubscriptionPlanTest { - - private fun plan(trialPeriod: String? = "P1M") = SubscriptionPlan( - productId = "me.shirobyte42.glosso.pro", - title = "Glosso Pro", - description = "Curso completo", - offerToken = "token", - basePlanId = "monthly", - offerId = "trial", - priceFormatted = "6,99 €", - recurringPeriodIso = "P1M", - freeTrialPeriod = trialPeriod, - ) - - @Test - fun `hasFreeTrial reflects the trial period`() { - assertTrue(plan(trialPeriod = "P1M").hasFreeTrial) - assertFalse(plan(trialPeriod = null).hasFreeTrial) - } - - @Test - fun `trial end comes from the trial period`() { - assertEquals( - BillingPeriods.endMillis(0L, "P1M"), - plan().trialEndMillis(0L), - ) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionResolverTest.kt b/androidApp/src/test/java/com/glosso/app/billing/SubscriptionResolverTest.kt deleted file mode 100644 index 64eb1cd..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionResolverTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.glosso.app.billing - -import java.time.Instant -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class SubscriptionResolverTest { - - private val start = Instant.parse("2026-01-01T00:00:00Z").toEpochMilli() - private val duringTrial = Instant.parse("2026-01-15T00:00:00Z").toEpochMilli() - private val afterTrial = Instant.parse("2026-03-01T00:00:00Z").toEpochMilli() - - private fun view( - purchased: Boolean = false, - pending: Boolean = false, - autoRenewing: Boolean = true, - purchaseTimeMillis: Long = start, - acknowledged: Boolean = true, - token: String = "token", - ) = PurchaseView( - productId = "me.shirobyte42.glosso.pro", - purchased = purchased, - pending = pending, - autoRenewing = autoRenewing, - purchaseTimeMillis = purchaseTimeMillis, - acknowledged = acknowledged, - token = token, - ) - - private val trialPlan = SubscriptionPlan( - productId = "me.shirobyte42.glosso.pro", - title = "Glosso Pro", - description = "", - offerToken = "offer", - basePlanId = "monthly", - offerId = "trial", - priceFormatted = "6,99 €", - recurringPeriodIso = "P1M", - freeTrialPeriod = "P1M", - ) - - @Test - fun `a purchased purchase beats a pending one`() { - val paid = view(purchased = true, acknowledged = false, token = "paid") - val pending = view(pending = true, token = "pending") - - val selection = SubscriptionResolver.select(listOf(pending, paid)) - - assertTrue(selection is PurchaseSelection.Paid) - val resolved = selection as PurchaseSelection.Paid - assertEquals("paid", resolved.purchase.token) - // A purchased-but-unacknowledged purchase must reach the caller so it is - // acknowledged, not hidden behind the pending one. - assertFalse(resolved.purchase.acknowledged) - assertEquals("pending", resolved.alsoPending?.token) - } - - @Test - fun `only a pending purchase is deferred`() { - val selection = SubscriptionResolver.select(listOf(view(pending = true))) - - assertTrue(selection is PurchaseSelection.Deferred) - } - - @Test - fun `nothing relevant is none`() { - assertEquals(PurchaseSelection.None, SubscriptionResolver.select(emptyList())) - } - - @Test - fun `a running trial is labelled trial when the offer phases are known`() { - val paid = view(purchased = true) - - assertEquals( - SubscriptionStatus.TRIAL, - SubscriptionResolver.statusFor(paid, trialPlan, duringTrial), - ) - } - - @Test - fun `after the trial it is active`() { - val paid = view(purchased = true) - - assertEquals( - SubscriptionStatus.ACTIVE, - SubscriptionResolver.statusFor(paid, trialPlan, afterTrial), - ) - } - - @Test - fun `an offer without trial is active from the start`() { - val noTrial = trialPlan.copy(freeTrialPeriod = null) - val paid = view(purchased = true) - - assertEquals( - SubscriptionStatus.ACTIVE, - SubscriptionResolver.statusFor(paid, noTrial, duringTrial), - ) - } - - @Test - fun `a canceled subscription keeps access until it is dropped`() { - val paid = view(purchased = true, autoRenewing = false) - - assertEquals( - SubscriptionStatus.CANCELED, - SubscriptionResolver.statusFor(paid, trialPlan, afterTrial), - ) - } - - @Test - fun `without the offer phases the status stays unknown`() { - // The race guard: do not call a trial ACTIVE just because the details have - // not loaded yet. - assertNull(SubscriptionResolver.statusFor(view(purchased = true), plan = null)) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionStatusTest.kt b/androidApp/src/test/java/com/glosso/app/billing/SubscriptionStatusTest.kt deleted file mode 100644 index 382e881..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/SubscriptionStatusTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The UI must render exactly the states Play and the fake can produce, without - * inventing an "expired" or "restored" state: an expired subscription is a - * dropped purchase ([NONE]) and a restore is a re-query, not a state. - */ -class SubscriptionStatusTest { - - @Test - fun `the six real states are the only ones`() { - assertEquals( - listOf("NONE", "TRIAL", "ACTIVE", "GRACE", "CANCELED", "PENDING"), - SubscriptionStatus.entries.map { it.name }, - ) - assertFalse(SubscriptionStatus.entries.any { it.name.contains("EXPIRED") }) - } - - @Test - fun `expired is no purchase and grants nothing`() { - assertFalse(SubscriptionStatus.NONE.isEntitled) - } - - @Test - fun `entitlement follows the documented rule`() { - val entitled = listOf( - SubscriptionStatus.TRIAL, - SubscriptionStatus.ACTIVE, - SubscriptionStatus.GRACE, - SubscriptionStatus.CANCELED, - ) - entitled.forEach { assertTrue("$it should be entitled", it.isEntitled) } - listOf(SubscriptionStatus.NONE, SubscriptionStatus.PENDING) - .forEach { assertFalse("$it should not be entitled", it.isEntitled) } - } -} diff --git a/androidApp/src/test/java/com/glosso/app/billing/TodayPlanTest.kt b/androidApp/src/test/java/com/glosso/app/billing/TodayPlanTest.kt deleted file mode 100644 index 811ca2f..0000000 --- a/androidApp/src/test/java/com/glosso/app/billing/TodayPlanTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.engine.model.LessonModel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class TodayPlanTest { - - private fun lesson(id: String, level: String = "A1", theme: String = "city") = - LessonModel(id = id, level = level, theme = theme, title = id) - - // The catalog arrives in server order: the free block (family) is NOT first, - // exactly like production, where the first lesson the server returns is a Pro - // one. Only the teaching order can pick the free lesson. - private val catalog = listOf( - lesson("city.1", theme = "city"), - lesson("city.2", theme = "city"), - lesson("family.1", theme = "family"), - lesson("family.2", theme = "family"), - lesson("food.1", theme = "food"), - ) - private val themeOrder = listOf("family", "travel", "city", "food") - private val free = catalog.first { it.id == "family.1" } - private val paid = catalog.first { it.id == "city.1" } - - @Test - fun `a new free learner gets the free first block, not the server's first lesson`() { - val plan = todayPlan(catalog, themeOrder, resumeLessonId = null, resumeInProgress = false, status = SubscriptionStatus.NONE) - - // The old firstOrNull() opened city.1, which is Pro. The gate must land on - // the same free lesson the lesson list shows. - assertEquals("family.1", plan.lesson?.id) - assertFalse(plan.resumable) - assertFalse(plan.locked) - } - - @Test - fun `a free learner whose next lesson is Pro gets the paywall, not the lesson`() { - val onlyA2 = listOf(lesson("a2.city.1", level = "A2", theme = "city")) - - val plan = todayPlan(onlyA2, themeOrder, resumeLessonId = null, resumeInProgress = false, status = SubscriptionStatus.NONE) - - assertTrue(plan.locked) - } - - @Test - fun `an entitled learner is never locked on Today`() { - val onlyA2 = listOf(lesson("a2.city.1", level = "A2", theme = "city")) - - for (status in listOf(SubscriptionStatus.TRIAL, SubscriptionStatus.ACTIVE)) { - val plan = todayPlan(onlyA2, themeOrder, resumeLessonId = null, resumeInProgress = false, status = status) - assertFalse("locked for $status", plan.locked) - } - } - - @Test - fun `an in-progress Pro lesson is resumed, never paywalled`() { - val plan = todayPlan(catalog, themeOrder, resumeLessonId = paid.id, resumeInProgress = true, status = SubscriptionStatus.NONE) - - assertEquals(paid.id, plan.lesson?.id) - assertTrue(plan.resumable) - assertFalse(plan.locked) - } - - @Test - fun `a resume target already at the summary falls back to the first teaching lesson`() { - val plan = todayPlan(catalog, themeOrder, resumeLessonId = paid.id, resumeInProgress = false, status = SubscriptionStatus.NONE) - - assertEquals(free.id, plan.lesson?.id) - assertFalse(plan.resumable) - } - - @Test - fun `a finished lesson is never offered again while another one is pending`() { - val plan = todayPlan( - catalog, - themeOrder, - resumeLessonId = null, - resumeInProgress = false, - status = SubscriptionStatus.NONE, - finishedLessonIds = listOf("family.1"), - ) - - // The bug: with nothing in progress Today fell back to family.1 forever, - // so the learner was always "on the first lesson". - assertEquals("family.2", plan.lesson?.id) - assertFalse(plan.resumable) - } - - @Test - fun `finishing a whole block moves Today to the next block in teaching order`() { - val plan = todayPlan( - catalog, - themeOrder, - resumeLessonId = null, - resumeInProgress = false, - status = SubscriptionStatus.TRIAL, - finishedLessonIds = listOf("family.1", "family.2"), - ) - - assertEquals("city.1", plan.lesson?.id) - } - - @Test - fun `the lesson in progress wins over the next unfinished one`() { - val plan = todayPlan( - catalog, - themeOrder, - resumeLessonId = "food.1", - resumeInProgress = true, - status = SubscriptionStatus.TRIAL, - finishedLessonIds = listOf("family.1"), - ) - - assertEquals("food.1", plan.lesson?.id) - assertTrue(plan.resumable) - } - - @Test - fun `with every lesson finished Today still offers a lesson`() { - val plan = todayPlan( - catalog, - themeOrder, - resumeLessonId = null, - resumeInProgress = false, - status = SubscriptionStatus.TRIAL, - finishedLessonIds = catalog.map { it.id }, - ) - - assertEquals("family.1", plan.lesson?.id) - } -} diff --git a/androidApp/src/test/java/com/glosso/app/platform/AndroidStorageCompatibilityTest.kt b/androidApp/src/test/java/com/glosso/app/platform/AndroidStorageCompatibilityTest.kt index 665664d..81f8585 100644 --- a/androidApp/src/test/java/com/glosso/app/platform/AndroidStorageCompatibilityTest.kt +++ b/androidApp/src/test/java/com/glosso/app/platform/AndroidStorageCompatibilityTest.kt @@ -258,11 +258,14 @@ class AndroidStorageCompatibilityTest { GoogleAccountStore.signedIn("other@example.com") assertEquals("other@example.com", prefs("glosso_google_account").getString("email", null)) + // The Application already started the repository (and its network refresh) + // before this test runs, so a second init is a no-op: check the bodies an + // existing install cached under the file and keys the repository reads. val config = ProductConfigRepository.encodeConfig(ProductConfigRepository.config.value) - prefs("glosso_product").edit().putString("config", config).putString("account", "{\"plan\":\"PRO\"}").commit() - ProductConfigRepository.api = { error("offline") } - ProductConfigRepository.init(app) - assertEquals("PRO", ProductConfigRepository.account.value?.plan) + prefs("glosso_product").edit().putString("config", config).putString("account", "{\"linkedAccounts\":[\"google\"]}").commit() + val cached = prefs("glosso_product") + assertEquals(ProductConfigRepository.config.value, ProductConfigRepository.decodeConfig(cached.getString("config", null)!!)) + assertEquals(listOf("google"), ProductConfigRepository.decodeAccount(cached.getString("account", null)!!)?.linkedAccounts) assertFalse(DownloadPermission.wasAsked(app)) prefs("glosso_downloads").edit().putBoolean("notification_prompted", true).commit() diff --git a/androidApp/src/test/java/com/glosso/app/platform/LocaleFormatParityTest.kt b/androidApp/src/test/java/com/glosso/app/platform/LocaleFormatParityTest.kt index 4d2f537..a900590 100644 --- a/androidApp/src/test/java/com/glosso/app/platform/LocaleFormatParityTest.kt +++ b/androidApp/src/test/java/com/glosso/app/platform/LocaleFormatParityTest.kt @@ -2,14 +2,10 @@ package com.glosso.app.platform import android.text.format.DateFormat import com.glosso.app.formatBytes -import com.glosso.app.product.formatBetaEndDate import java.text.NumberFormat import java.time.DayOfWeek -import java.time.Instant import java.time.LocalDate -import java.time.ZoneOffset import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.time.format.TextStyle import java.util.Locale import org.junit.After @@ -87,42 +83,6 @@ class LocaleFormatParityTest { assertEquals("512 B", formatBytes(512L, "es")) } - /** `formatBetaEndDate` as the Android app had it (BetaEndDate.kt before the move). */ - private fun legacyBetaEndDate(iso: String?): String? { - val value = iso?.takeIf { it.isNotBlank() } ?: return null - return runCatching { - Instant.parse(value).atZone(ZoneOffset.UTC).toLocalDate() - .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)) - }.getOrNull() - } - - @Test - fun `the beta end date reads as before in every app language`() { - val dates = listOf( - "2027-12-31T23:59:59Z", "2026-12-31T23:59:59Z", "2027-01-01T00:00:00Z", "2027-06-15T12:30:00Z", - "2028-02-29T10:00:00Z", "2027-12-31T23:59:59.999Z", null, "", " ", "not-a-date", "2027-12-31", - "2027-12-31T23:59:59+01:00", - ) - for (tag in locales) { - Locale.setDefault(Locale.forLanguageTag(tag)) - for (iso in dates) assertEquals("$iso in $tag", legacyBetaEndDate(iso), formatBetaEndDate(iso)) - } - } - - @Test - fun `the beta end date fixed strings`() { - val cases = mapOf( - "en-US" to "December 31, 2027", - "es-ES" to "31 de diciembre de 2027", - "fr-FR" to "31 décembre 2027", - "de-DE" to "31. Dezember 2027", - ) - for ((tag, expected) in cases) { - Locale.setDefault(Locale.forLanguageTag(tag)) - assertEquals(tag, expected, formatBetaEndDate("2027-12-31T23:59:59Z")) - } - } - @Test fun `the Today date line reads as before`() { val days = listOf(LocalDate.of(2026, 9, 25), LocalDate.of(2027, 1, 1), LocalDate.of(2028, 2, 29), LocalDate.of(2026, 12, 31)) diff --git a/androidApp/src/test/java/com/glosso/app/product/ProductConfigRepositoryTest.kt b/androidApp/src/test/java/com/glosso/app/product/ProductConfigRepositoryTest.kt index ac35b09..818e182 100644 --- a/androidApp/src/test/java/com/glosso/app/product/ProductConfigRepositoryTest.kt +++ b/androidApp/src/test/java/com/glosso/app/product/ProductConfigRepositoryTest.kt @@ -1,23 +1,16 @@ package com.glosso.app.product -import com.glosso.app.api.ProductConfig import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** - * The repository's decode path against the bodies the server really serves. - * - * [ProductConfigRepository.refresh] keeps a fetched body only when it decodes; - * a `JsonDecodingException` is swallowed and the state silently stays on - * [ProductConfig.BetaFallback]. The mock used by the E2E answered a simplified - * shape, so the real bodies were never decoded until production. These tests - * feed the real `GET /v1/config` (with `"limits": null` and `"pricing": null`) - * and `GET /v1/me` (with `betaFounderBenefit` as a string) to the exact decode - * the repository uses. + * The repository's decode path. [ProductConfigRepository.refresh] keeps a + * fetched body only when it decodes; a decoding failure is swallowed and the + * state silently stays where it was, so a body the server really serves must + * never be dropped. */ class ProductConfigRepositoryTest { @@ -27,52 +20,29 @@ class ProductConfigRepositoryTest { .use { it.readText() } @Test - fun `the real config body is kept, not discarded`() { - val config = ProductConfigRepository.decodeConfig(fixture("server-product-config.json")) + fun `a legacy config body is kept, not discarded`() { + val config = ProductConfigRepository.decodeConfig(fixture("legacy-product-config.json")) - assertNotNull("a real body must not be dropped for a null limits/pricing", config) - assertTrue(config!!.isBeta) - assertEquals("2026-12-31T23:59:59Z", config.beta.endDate) - assertNull(config.limits) - assertNull(config.pricing) - assertEquals(setOf("FREE", "PRO", "PERSONALIZED"), config.plans.keys) - assertTrue(config.flag("beta_banner")) + assertNotNull("a body with null limits/pricing must not be dropped", config) + assertTrue(config!!.flag("beta_banner")) + assertEquals(2219, config.client.recommendedVersion) } @Test - fun `the real me body is kept, not discarded`() { - val account = ProductConfigRepository.decodeAccount(fixture("server-me.json")) - - assertNotNull(account) - assertEquals("FREE", account!!.plan) - assertTrue(account.betaFounder) - assertEquals("Ventaja Beta Founder", account.betaFounderBenefit) + fun `a legacy me body is kept, not discarded`() { + assertNotNull(ProductConfigRepository.decodeAccount(fixture("legacy-me.json"))) } @Test - fun `a garbage body is still discarded so the fallback stays`() { + fun `a garbage body is still discarded`() { assertNull(ProductConfigRepository.decodeConfig("not json")) assertNull(ProductConfigRepository.decodeAccount("not json")) } @Test - fun `the real bodies round-trip through the cache encoding`() { - val config = ProductConfigRepository.decodeConfig(fixture("server-product-config.json"))!! - val encoded = ProductConfigRepository.encodeConfig(config) - val reloaded = ProductConfigRepository.decodeConfig(encoded) - assertNotNull(reloaded) - assertTrue(reloaded!!.isBeta) - assertNull(reloaded.limits) - assertNull(reloaded.pricing) - } - - @Test - fun `a config missing mode still assumes the open beta, never a silent lock`() { - val partial = """{"beta":{"active":true,"endDate":"2026-12-31T23:59:59Z"},"flags":{}}""" - val config = ProductConfigRepository.decodeConfig(partial) - - assertNotNull(config) - assertTrue("the server default is BETA, and the safe client default is too", config!!.isBeta) - assertFalse(config.flag("pro")) + fun `a config round-trips through the cache encoding`() { + val config = ProductConfigRepository.decodeConfig(fixture("legacy-product-config.json"))!! + val reloaded = ProductConfigRepository.decodeConfig(ProductConfigRepository.encodeConfig(config)) + assertEquals(config, reloaded) } } diff --git a/androidApp/src/test/resources/fixtures/server-me.json b/androidApp/src/test/resources/fixtures/legacy-me.json similarity index 100% rename from androidApp/src/test/resources/fixtures/server-me.json rename to androidApp/src/test/resources/fixtures/legacy-me.json diff --git a/androidApp/src/test/resources/fixtures/server-product-config.json b/androidApp/src/test/resources/fixtures/legacy-product-config.json similarity index 100% rename from androidApp/src/test/resources/fixtures/server-product-config.json rename to androidApp/src/test/resources/fixtures/legacy-product-config.json diff --git a/androidApp/src/testDebug/java/com/glosso/app/billing/DebugBillingConfigTest.kt b/androidApp/src/testDebug/java/com/glosso/app/billing/DebugBillingConfigTest.kt deleted file mode 100644 index f2a1ecd..0000000 --- a/androidApp/src/testDebug/java/com/glosso/app/billing/DebugBillingConfigTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.BuildConfig -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Runs only for the debug variant (`:androidApp:testDebugUnitTest`), so it pins - * the default debug contract: debuggable, and the fake unless a license-tester - * build explicitly asked for the real client with `-PglossoBilling=real`. That - * override is a different build and intentionally not covered here. - */ -class DebugBillingConfigTest { - - @Test - fun `debug ships the fake by default`() { - assertTrue(BuildConfig.DEBUG) - assertTrue(BuildConfig.BILLING_FAKE) - assertTrue(BillingProvider.useFake(BuildConfig.BILLING_FAKE, BuildConfig.DEBUG)) - } -} diff --git a/androidApp/src/testDebug/java/com/glosso/app/billing/FakeSubscriptionRepositoryTest.kt b/androidApp/src/testDebug/java/com/glosso/app/billing/FakeSubscriptionRepositoryTest.kt deleted file mode 100644 index b5102fd..0000000 --- a/androidApp/src/testDebug/java/com/glosso/app/billing/FakeSubscriptionRepositoryTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class FakeSubscriptionRepositoryTest { - - private val repo = FakeSubscriptionRepository() - - @Test - fun `starts with no subscription`() { - assertEquals(SubscriptionStatus.NONE, repo.status.value) - assertEquals(FakeSubscriptionRepository.devPlan, repo.plan.value) - } - - @Test - fun `subscribing starts the trial`() { - repo.simulateSubscribe() - assertEquals(SubscriptionStatus.TRIAL, repo.status.value) - } - - @Test - fun `trial can become active or canceled`() { - repo.simulateActivate() - assertEquals(SubscriptionStatus.ACTIVE, repo.status.value) - - repo.simulateCancel() - assertEquals(SubscriptionStatus.CANCELED, repo.status.value) - } - - @Test - fun `grace and pending are reported as states`() { - repo.simulateGrace() - assertEquals(SubscriptionStatus.GRACE, repo.status.value) - - repo.simulatePending() - assertEquals(SubscriptionStatus.PENDING, repo.status.value) - } - - @Test - fun `errors can be raised and cleared`() { - repo.simulateError("sin red") - assertEquals("sin red", repo.error.value) - - repo.clearError() - assertNull(repo.error.value) - } - - @Test - fun `restoring clears the last error`() { - repo.simulateError("sin red") - - repo.refresh(restore = true) - - assertNull(repo.error.value) - } - - @Test - fun `an offer without a trial activates immediately`() { - val noTrial = FakeSubscriptionRepository.devPlan.copy(freeTrialPeriod = null) - val paid = FakeSubscriptionRepository(planValue = noTrial) - - paid.simulateSubscribe() - - assertEquals(SubscriptionStatus.ACTIVE, paid.status.value) - } - - @Test - fun `subscribing twice never demotes an existing subscription`() { - repo.simulateActivate() - - repo.simulateSubscribe() - - assertEquals(SubscriptionStatus.ACTIVE, repo.status.value) - } - - @Test - fun `reset returns to a fresh install`() { - repo.simulateActivate() - repo.reset() - assertEquals(SubscriptionStatus.NONE, repo.status.value) - assertNull(repo.error.value) - } -} diff --git a/androidApp/src/testDebug/java/com/glosso/app/debugtools/ModelCompareDebugTest.kt b/androidApp/src/testDebug/java/com/glosso/app/debugtools/ModelCompareDebugTest.kt index edb4e03..3441d63 100644 --- a/androidApp/src/testDebug/java/com/glosso/app/debugtools/ModelCompareDebugTest.kt +++ b/androidApp/src/testDebug/java/com/glosso/app/debugtools/ModelCompareDebugTest.kt @@ -4,7 +4,7 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Runs only for the debug variant (`:androidApp:testDebugUnitTest`): the model + * Runs only for the debug variant (`:androidApp:testDebugUnitTest`): the model * comparison tool is compiled in and its entry point is reachable. */ class ModelCompareDebugTest { diff --git a/androidApp/src/testFdroid/java/com/glosso/app/FdroidFlavorTest.kt b/androidApp/src/testFdroid/java/com/glosso/app/FdroidFlavorTest.kt new file mode 100644 index 0000000..cdb2bef --- /dev/null +++ b/androidApp/src/testFdroid/java/com/glosso/app/FdroidFlavorTest.kt @@ -0,0 +1,39 @@ +package com.glosso.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.fail +import org.junit.Test + +/** The F-Droid build has no Google sign-in and no Google libraries, and statistics start off. */ +class FdroidFlavorTest { + + @Test + fun `no sign-in is installed, so Change phone stays hidden`() { + Flavor.install() + + assertSame(UnavailableSignIn, accountSignIn) + assertFalse(accountSignIn.isConfigured) + } + + @Test + fun `no Google sign-in or Play services class is on the classpath`() { + listOf( + "com.google.android.libraries.identity.googleid.GetGoogleIdOption", + "com.google.android.gms.common.GoogleApiAvailability", + "androidx.credentials.playservices.CredentialProviderPlayServicesImpl", + ).forEach { name -> + try { + Class.forName(name) + fail("$name must not be in the F-Droid build") + } catch (_: ClassNotFoundException) { + } + } + } + + @Test + fun `usage statistics are off until the learner turns them on`() { + assertEquals(false, BuildConfig.USAGE_STATISTICS_DEFAULT) + } +} diff --git a/androidApp/src/testPlaystore/java/com/glosso/app/PlaystoreFlavorTest.kt b/androidApp/src/testPlaystore/java/com/glosso/app/PlaystoreFlavorTest.kt new file mode 100644 index 0000000..b3d6412 --- /dev/null +++ b/androidApp/src/testPlaystore/java/com/glosso/app/PlaystoreFlavorTest.kt @@ -0,0 +1,22 @@ +package com.glosso.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** The Google Play build keeps Sign in with Google and statistics on by default, as before. */ +class PlaystoreFlavorTest { + + @Test + fun `Google sign-in is the account sign-in`() { + Flavor.install() + + assertSame(GoogleSignIn, accountSignIn) + assertEquals(BuildConfig.GOOGLE_SERVER_CLIENT_ID.isNotBlank(), accountSignIn.isConfigured) + } + + @Test + fun `usage statistics keep their default on`() { + assertEquals(true, BuildConfig.USAGE_STATISTICS_DEFAULT) + } +} diff --git a/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseBillingConfigTest.kt b/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseBillingConfigTest.kt deleted file mode 100644 index 2445c99..0000000 --- a/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseBillingConfigTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.BuildConfig -import org.junit.Assert.assertFalse -import org.junit.Test - -/** - * Runs only for the release variant (`:androidApp:testReleaseUnitTest`), so it - * reads the real release [BuildConfig] the shipped APK is built with. - */ -class ReleaseBillingConfigTest { - - @Test - fun `release is not debuggable and never ships the fake`() { - assertFalse(BuildConfig.DEBUG) - assertFalse(BuildConfig.BILLING_FAKE) - assertFalse(BillingProvider.useFake(BuildConfig.BILLING_FAKE, BuildConfig.DEBUG)) - } -} diff --git a/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseEntitlementGuardTest.kt b/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseEntitlementGuardTest.kt deleted file mode 100644 index 8f6a60b..0000000 --- a/androidApp/src/testRelease/java/com/glosso/app/billing/ReleaseEntitlementGuardTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.glosso.app.billing - -import org.junit.Assert.assertNull -import org.junit.Assert.fail -import org.junit.Test - -/** - * Runs only for the release variant (`:androidApp:testReleaseUnitTest`). Pins the - * two facts that make the debug Pro unlock impossible in release: - * - * 1. the only entitlement seam the release binary has ([debugFakeBilling]) - * returns no repository at all, so there is no fake to select; - * 2. `FakeSubscriptionRepository` is not even on the release classpath, so a - * release build has no class that could set `SubscriptionStatus.ACTIVE`. - * - * `:androidApp:verifyReleaseApkEntitlement` and - * `:androidApp:verifyReleaseBundleEntitlement` check the packaged APK and AAB for - * the same absence at the artifact level. - */ -class ReleaseEntitlementGuardTest { - - @Test - fun `the release fake seam produces no repository`() { - assertNull(debugFakeBilling()) - } - - @Test - fun `the fake repository is not on the release classpath`() { - try { - Class.forName("com.glosso.app.billing.FakeSubscriptionRepository") - fail("FakeSubscriptionRepository must not be compiled into release") - } catch (expected: ClassNotFoundException) { - // expected: the debug source set is not part of the release build - } - } -} diff --git a/androidApp/src/testRelease/java/com/glosso/app/debugtools/ModelCompareReleaseTest.kt b/androidApp/src/testRelease/java/com/glosso/app/debugtools/ModelCompareReleaseTest.kt index 4bfc710..7093f89 100644 --- a/androidApp/src/testRelease/java/com/glosso/app/debugtools/ModelCompareReleaseTest.kt +++ b/androidApp/src/testRelease/java/com/glosso/app/debugtools/ModelCompareReleaseTest.kt @@ -4,7 +4,7 @@ import org.junit.Assert.assertFalse import org.junit.Test /** - * Runs only for the release variant (`:androidApp:testReleaseUnitTest`): pins the + * Runs only for the release variant (`:androidApp:testReleaseUnitTest`): pins the * guard that keeps the model comparison tool out of a release build. The debug * source set holds the comparator; in release [modelCompareAvailable] is a * constant false and the screen is an empty stub. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 32c0c90..dc65c32 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -131,37 +131,16 @@ services: CARTESIA_API_KEY: ${CARTESIA_API_KEY:-} CARTESIA_VERSION: ${CARTESIA_VERSION:-2026-08-14} CARTESIA_DEFAULT_VOICE: ${CARTESIA_DEFAULT_VOICE:-} - # Billing Play (ver docs/BILLING-E2E.md). Preferir base64 en Docker. - GLOSSO_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${GLOSSO_PLAY_SERVICE_ACCOUNT_JSON_BASE64:-} - GLOSSO_PLAY_PACKAGE: ${GLOSSO_PLAY_PACKAGE:-me.shirobyte42.glosso} - GLOSSO_PLAY_PRODUCT_ID: ${GLOSSO_PLAY_PRODUCT_ID:-me.shirobyte42.glosso.pro} - GLOSSO_PLAY_TRIAL_OFFER_ID: ${GLOSSO_PLAY_TRIAL_OFFER_ID:-} - GLOSSO_BILLING_CACHE_TTL_SECONDS: ${GLOSSO_BILLING_CACHE_TTL_SECONDS:-600} - GLOSSO_RTDN_SECRET: ${GLOSSO_RTDN_SECRET:-} # Llevar la cuenta a otro movil entrando con Google (docs/BETA-RUNBOOK.md # 6.1, docs/GOOGLE-SIGNIN.md). Sin secreto o sin client id responde 503. GLOSSO_ACCOUNT_SECRET: ${GLOSSO_ACCOUNT_SECRET:-} GLOSSO_GOOGLE_CLIENT_ID: ${GLOSSO_GOOGLE_CLIENT_ID:-} - GLOSSO_RTDN_SUBSCRIPTION: ${GLOSSO_RTDN_SUBSCRIPTION:-} - # Config de producto (docs/PRODUCT-CONFIG.md). Vacio = el default seguro - # del servidor (ProductConfig.fromEnv), asi que ninguna fecha ni precio se - # fija aqui. Un flag nuevo GLOSSO_FLAG_ se anade a esta lista. - GLOSSO_PRODUCT_MODE: ${GLOSSO_PRODUCT_MODE:-} - GLOSSO_BETA_END_DATE: ${GLOSSO_BETA_END_DATE:-} - GLOSSO_BETA_FOUNDER_ENABLED: ${GLOSSO_BETA_FOUNDER_ENABLED:-} - GLOSSO_BETA_FOUNDER_UNTIL: ${GLOSSO_BETA_FOUNDER_UNTIL:-} - GLOSSO_BETA_FOUNDER_BENEFIT_KIND: ${GLOSSO_BETA_FOUNDER_BENEFIT_KIND:-} - GLOSSO_BETA_FOUNDER_BENEFIT_VALUE: ${GLOSSO_BETA_FOUNDER_BENEFIT_VALUE:-} - GLOSSO_BETA_FOUNDER_BENEFIT_LABEL: ${GLOSSO_BETA_FOUNDER_BENEFIT_LABEL:-} + # Product config (docs/PRODUCT-CONFIG-CONTRACT.md). Empty = the server's + # safe default (ProductConfig.fromEnv). A new GLOSSO_FLAG_ is added + # to this list. GLOSSO_MIN_CLIENT_VERSION: ${GLOSSO_MIN_CLIENT_VERSION:-} GLOSSO_RECOMMENDED_CLIENT_VERSION: ${GLOSSO_RECOMMENDED_CLIENT_VERSION:-} GLOSSO_FORCE_UPDATE: ${GLOSSO_FORCE_UPDATE:-} - GLOSSO_PLANS_JSON: ${GLOSSO_PLANS_JSON:-} - GLOSSO_LIMITS_JSON: ${GLOSSO_LIMITS_JSON:-} - GLOSSO_PRICING_JSON: ${GLOSSO_PRICING_JSON:-} - GLOSSO_FLAG_BETA_BANNER: ${GLOSSO_FLAG_BETA_BANNER:-} - GLOSSO_FLAG_PRO: ${GLOSSO_FLAG_PRO:-} - GLOSSO_FLAG_PERSONALIZED: ${GLOSSO_FLAG_PERSONALIZED:-} GLOSSO_FLAG_ACCOUNTS: ${GLOSSO_FLAG_ACCOUNTS:-} GLOSSO_FLAG_ADAPTIVE_EXERCISES: ${GLOSSO_FLAG_ADAPTIVE_EXERCISES:-} GLOSSO_FLAG_ADVANCED_ANALYTICS: ${GLOSSO_FLAG_ADVANCED_ANALYTICS:-} diff --git a/docs/ADD_LANGUAGE.md b/docs/ADD_LANGUAGE.md index 0619be4..d6fddb4 100644 --- a/docs/ADD_LANGUAGE.md +++ b/docs/ADD_LANGUAGE.md @@ -335,7 +335,7 @@ Si no se usa `publishContent` (p. ej. solo datos), recuerda que el servidor lee Tests y build: ```sh -./gradlew :engine:jvmTest :server:test :androidApp:testDebugUnitTest +./gradlew :engine:jvmTest :server:test :androidApp:testFdroidDebugUnitTest :androidApp:testPlaystoreDebugUnitTest ./gradlew build ``` diff --git a/docs/BILLING.md b/docs/BILLING.md index 2b35d55..1683fcd 100644 --- a/docs/BILLING.md +++ b/docs/BILLING.md @@ -1,292 +1,23 @@ -# Glosso - Suscripcion con Google Play Billing +# Billing (removed) -Estado: **capa de suscripcion implementada y compilando; la compra real NO esta -verificada de extremo a extremo**. Ver la seccion de honestidad antes de dar nada -por bueno. El detalle de que se probo de verdad (fake en emulador + tests) y los -pasos exactos que quedan para el dueno estan en `docs/BILLING-E2E.md`. +Glosso is free for everyone, forever. There is no paid tier, no subscription and no in-app purchase. +Anyone who wants to help keep the servers running can make a voluntary donation through Ko-fi from the +About screen; a donation unlocks nothing, because nothing is locked. -## Que hay implementado +The app never sold anything: it always ran in an open beta in which the server unlocked every lesson, so +there were no paying users to migrate. -- **Play Billing Library** en `:androidApp` (`com.android.billingclient:billing`, - version en `gradle/libs.versions.toml`). -- **Suscripcion gestionada** leida de Play: base plan mas oferta, con el - `offerToken` de la oferta (no el base plan a secas) para que el mes de prueba se - aplique de verdad. El precio y el periodo de prueba salen siempre de Play - (`queryProductDetailsAsync`), nunca estan escritos a mano. -- **Capa separada**: `SubscriptionRepository` (interfaz) con - `PlaySubscriptionRepository` (real) y `FakeSubscriptionRepository` - (desarrollo/tests). Estado como enum: - `NONE / TRIAL / ACTIVE / GRACE / CANCELED / PENDING`. -- **Ciclo de vida**: conexion y reconexion con backoff, `queryPurchasesAsync` al - arrancar y en cada `ON_RESUME` (restaurar y revalidar), `launchBillingFlow` con - el `offerToken`, `acknowledgePurchase` (obligatorio: sin reconocer, Play - reembolsa a los 3 dias), compras `PENDING` (pago diferido) y errores de red como - estado, sin romper la app. +What was removed: -### Reglas de resolucion de estado +- the Google Play Billing client, the paywall and every Pro lock in the app; +- server-side purchase verification (`POST /v1/billing/verify`), the Real-time Developer Notifications + webhook (`POST /v1/billing/rtdn`), and the plan, lifetime-grant and Beta Founder logic behind `/v1/me`; +- the related environment variables (`GLOSSO_PLAY_*`, `GLOSSO_BILLING_CACHE_TTL_SECONDS`, `GLOSSO_RTDN_*`, + `GLOSSO_PRODUCT_MODE`, `GLOSSO_BETA_*`, `GLOSSO_PLANS_JSON`, `GLOSSO_LIMITS_JSON`, `GLOSSO_PRICING_JSON`). -- Una compra `PURCHASED` **gana siempre** a una `PENDING`: una compra pagada nunca - se oculta bajo un pago pendiente, y se reconoce siempre que no lo este. -- `TRIAL` y `ACTIVE` solo se distinguen **cuando las fases de la oferta ya estan - cargadas**. Si el estado se resuelve antes, se espera y se recalcula al llegar - los detalles, para no etiquetar una prueba como `ACTIVE`. -- `PENDING` no da entitlement; con una compra pendiente el CTA del paywall queda - deshabilitado y se pide completar el pago en Google Play. -- No se envian parametros de reemplazo o upgrade/downgrade: el flujo solo lleva el - `offerToken`, asi que no puede cambiar prorrateos de una suscripcion existente - por accidente. -- Un error se limpia en cuanto la operacion se recupera (conexion, detalles, - consulta de compras) o cuando el usuario reintenta (`refresh`). -- **Paywall** (`PaywallSheet.kt`): plan con "1 mes gratis, luego X al mes" a partir - de los datos de Play, CTA, restaurar compras, enlace para gestionar/cancelar en - Google Play y aviso legal de renovacion automatica. -- **Gate real** en un unico sitio (`billing/AccessPolicy.kt` + `billing/AccessController.kt`): - gratis = todo A1 (lecciones, historias, ejercicios y drills A1) mas el banco de - practica libre general; Pro = A2/B1/B2, la practica personalizada y el perfil - fonetico. La frontera es el nivel CEFR y nunca cae dentro de una leccion: si una - leccion se puede empezar gratis, se puede terminar gratis, y una leccion ya - empezada se puede continuar siempre. Las descargas offline espejan la misma - frontera. -- **Por que la personalizacion es Pro.** El paywall ya promete *"Practice and - phonetic profile included"*: si el plan personalizado y el perfil fonetico - quedaran abiertos, la promesa no entregaria nada. Y justo eso es lo que un - alumno no puede montar por su cuenta: su plan (derivado de su perfil) y su mapa - fonetico. El banco de practica libre general (A1) si queda gratis, como gancho - para probar sin limites. -- **Toda superficie pasa por el mismo gate.** Cada sitio que abre contenido - (lecciones, historias, ejercicios, practica, perfil y Hoy) pregunta a - `AccessController`, que decide por item (cualquier `ContentMeta` por su nivel) o - por funcion (`ProFeature`). Ninguna pantalla re-deriva la frontera; un test de - auditoria de fuentes (`ContentGateTest`) falla si aparece un CTA que abra - contenido sin el gate. +The database was left alone. `glosso.entitlements`, `glosso.lifetime_grants` and the `beta_founder*` +columns of `glosso.users` (migrations V4 to V6) stay in place and are no longer written. The only code that +still touches them is the account move on Google sign-in, which carries those rows over to the account +instead of letting them be deleted with the placeholder user. -### Ficheros - -| Fichero | Papel | -| --- | --- | -| `androidApp/.../billing/SubscriptionStatus.kt` | El enum y la regla de entitlement | -| `androidApp/.../billing/SubscriptionPlan.kt` | La oferta tal como la devuelve Play | -| `androidApp/.../billing/BillingPeriods.kt` | ISO-8601 (`P1M`, `P7D`) a duracion | -| `androidApp/.../billing/BillingText.kt` | Duracion y oferta en el idioma de la interfaz | -| `androidApp/.../billing/SubscriptionRepository.kt` | La interfaz | -| `androidApp/.../billing/PlaySubscriptionRepository.kt` | Implementacion real | -| `androidApp/src/debug/.../billing/FakeSubscriptionRepository.kt` | Doble de desarrollo, **solo debug** (no se empaqueta en release) | -| `androidApp/.../billing/AccessPolicy.kt` | Que es gratis y que es de pago | -| `androidApp/.../billing/BillingProvider.kt` | Elige real o fake (`debugFakeBilling` por source set) | -| `androidApp/.../SubscriptionViewModel.kt` | Estado para la UI | -| `androidApp/.../PaywallSheet.kt` | La hoja de pago | - -## Honestidad: que se puede y que no se puede probar - -- **No se puede probar una compra real en local.** Google no ofrece dobles - oficiales y `launchBillingFlow` solo funciona con la app instalada desde un - track de pruebas de Play y una cuenta en *license testers*. -- Por eso existe el **fake**: en `debug` el flujo completo (paywall, CTA, gate de - lecciones y de descargas, estados) se ejercita sin Play. Lo que el fake **no** - prueba es la integracion con Google (conexion, compra, reconocimiento, - restaurar en un dispositivo real). Eso queda **pendiente de validar en un track - de pruebas**. -- La deteccion de `TRIAL` en el cliente se estima con la hora de compra mas la - duracion de la prueba leida de Play. `GRACE` no se puede ver desde el cliente: - la fija la verificacion en servidor (fase 2) o el fake. - -## Configuracion de build - -| Propiedad Gradle | Por defecto | Que hace | -| --- | --- | --- | -| `glossoBilling` | `fake` (debug) | `real` usa el cliente real de Play en debug | -| `glossoSubscriptionId` | `me.shirobyte42.glosso.pro` | El id de la suscripcion (debe coincidir con la identidad publicada y con el producto de Play) | - -El id del producto **debe** pasar `-PglossoSubscriptionId=...` cuando no coincida -con el default, sobre todo para un build de license tester. En Play Console ese id -es practicamente inmutable, asi que conviene fijarlo antes de crear el producto. - -`release` **siempre** usa el cliente real; nunca cae al fake. La decision no -depende solo del flag: `BillingProvider.useFake` exige `BILLING_FAKE && DEBUG`, -asi que un build no debuggable jamas entrega el fake aunque la propiedad este -mal. Ademas el propio `FakeSubscriptionRepository` vive en `src/debug`, de modo -que no se compila en release. Lo fijan `BillingProviderTest`, -`DebugBillingConfigTest` y `ReleaseBillingConfigTest` (ver `docs/BILLING-E2E.md`). - -```sh -# Debug con el fake (flujo completo sin Play) -./gradlew :androidApp:assembleDebug - -# Debug contra Play real, para un build de license tester -./gradlew :androidApp:assembleDebug -PglossoBilling=real \ - -PglossoSubscriptionId=me.shirobyte42.glosso.pro -``` - -## Pro desbloqueado en la build debug (y por que no puede pasar en release) - -La build **debug** arranca con Glosso Pro ya desbloqueado: `debugFakeBilling()` -construye `FakeSubscriptionRepository(initialStatus = ACTIVE)`, asi que al abrir -la app las superficies Pro son accesibles sin recorrer el flujo de compra fake. - -Es una **comodidad de desarrollo**, no un interruptor de producto. Para volver a -ver el muro y probar el paywall, quita el argumento `initialStatus` en -`androidApp/src/debug/.../billing/FakeBilling.kt` (el default de -`FakeSubscriptionRepository` es `NONE`) o llama a `reset()` desde los hooks de -test del fake. - -### Por que es fisicamente imposible en release - -- El desbloqueo vive **solo** en `src/debug`. No hay ningun `if (BuildConfig.DEBUG)` - ni parametro de runtime en codigo compartido (`main`) que active Pro. -- En release, `debugFakeBilling()` es un stub que devuelve `null`, y - `FakeSubscriptionRepository` **no se compila**. Un APK de release no contiene ni - la clase ni la ruta que podria poner el estado en `ACTIVE`: no hay nada que - cambiar para desbloquear. -- `BillingProvider.useFake` sigue exigiendo `BILLING_FAKE && BuildConfig.DEBUG`, y - `BuildConfig.DEBUG` es `false` en release en tiempo de compilacion. - -### Evidencia (tres capas) - -| Capa | Que fija | Donde | -| --- | --- | --- | -| Auditoria de fuentes | Ningun source set de produccion referencia el fake ni un override de entitlement | `ProductionEntitlementGuardTest` (`:androidApp:test`) | -| Guard por variante | En release el seam devuelve `null` y el fake no esta en el classpath | `ReleaseEntitlementGuardTest` (`:androidApp:testReleaseUnitTest`) | -| Artefacto | Ni el APK ni el AAB de release contienen `FakeSubscriptionRepository` en ningun dex | `:androidApp:verifyReleaseApkEntitlement` (tras `assembleRelease`) y `:androidApp:verifyReleaseBundleEntitlement` (tras `bundleRelease`). El AAB es lo que Play publica de verdad; `:androidApp:verifyReleaseEntitlement` comprueba los dos | - -Lo que este cambio **si** garantiza es estrecho y verificable sobre el binario: el -desbloqueo de debug esta confinado fisicamente al source set `debug`, no hay -ningun flag, booleano, extra ni constante que lo active en release, y el APK y el -AAB de release no contienen el fake en ningun dex. El guard se rompio a proposito -(se movio el fake a `main`) y falla, tanto para el APK como para el AAB. - -Lo que **hoy no** existe: el cliente **no** consulta la verificacion en servidor. -`PlaySubscriptionRepository` decide el entitlement por si solo a partir de lo que -devuelve Play Billing, sin llamar a `POST /v1/billing/verify`, asi que **quien -controle el cliente controla el entitlement**. Es una **limitacion conocida y -pendiente**, no una defensa vigente; es preexistente y este cambio no la toca. - -Conectarlo **no se puede hacer todavia**: `/v1/billing/verify` **falla cerrado** -(503 mientras no haya service account; nunca concede Pro) y el setup de Play -Console + Google Cloud esta pendiente. Conectar el cliente antes de ese setup -dejaria a todo el mundo sin Pro, porque la verificacion no podria resolver ningun -token. Va despues del setup; el contrato y los pasos estan en "Verificacion en -servidor (fase 2, contrato ya definido)". Ver `server/.../billing/BillingService.kt`. - -## Pasos exactos en Play Console - -1. **Crear o abrir la app.** Package `me.shirobyte42.glosso` (la identidad - publicada). Necesita al menos un build subido para poder configurar productos. -2. **Subir un AAB a un track.** Monetizacion y productos no se pueden activar - hasta que existe un artefacto. Sube primero (ver punto 6). -3. **Crear la suscripcion.** Monetizacion > Productos > Suscripciones > Crear - suscripcion. - - Id del producto: `me.shirobyte42.glosso.pro` (el mismo que - `glossoSubscriptionId`). Si se pasa otro con `-PglossoSubscriptionId=...`, - ese es el que hay que crear. - - Nombre y descripcion visibles para el usuario. -4. **Crear el base plan.** Dentro de la suscripcion, anade un base plan: - - Tipo: auto-renovable. - - Periodo de facturacion: mensual (el que quieras; el texto del paywall se - construye con lo que devuelva Play). - - Precio. -5. **Anadir la oferta con 1 mes gratis.** En el base plan, "Anadir oferta": - - Fase de elegibilidad: nuevo cliente. - - Fase de precio: **1 mes gratis** (precio 0), duracion 1 mes. - - El resto de fases: el precio mensual. - - Activa el base plan y la oferta. -6. **Track de pruebas internas.** - - Pruebas > Pruebas internas > Crear nueva version. - - Sube el AAB firmado de release. - - Anade los correos de prueba en la lista de testers. - - Publica y copia el enlace de opt-in. -7. **License testers.** - - Configuracion > Pruebas de licencia. - - Anade los correos de las cuentas de prueba. - - Respuesta de licencia: `RESPOND_NORMALLY` (o la que necesites). - - En el dispositivo, instala desde el enlace de opt-in del track internos con - una de esas cuentas. - -Nota: un pedido de prueba se puede cancelar/renovar sin coste en -"Mis pedidos" del testeador; los reembolsos y cambios de estado se pueden forzar -desde el propio pedido. - -## Como probarlo - -### Sin Play (fake, debug) - -1. `./gradlew :androidApp:assembleDebug` (usa el fake por defecto). -2. Abre la app, ve a **Biblioteca > Glosso Pro** o toca una leccion bloqueada o - el icono de descarga offline. -3. El paywall muestra el plan de desarrollo y "1 mes gratis, luego 6,99 EUR al - mes". El CTA pasa el estado a `TRIAL`, se abre el resto del curso (A2, B1 y B2; - el A1 entero ya era gratis) y las descargas se habilitan. - "Restaurar compras" es no-op. Los fallos se simulan con - `FakeSubscriptionRepository.simulateError(...)`. -4. Esto valida UI, gate y estados, no la integracion con Google. - -### Con Play (license testers) - -1. Prepara el build con `-PglossoBilling=real` y el `glossoSubscriptionId` - correcto. -2. Instalalo desde el enlace de opt-in del track de pruebas internas con una - cuenta de license tester. -3. Comprueba: precios reales en el paywall, `launchBillingFlow` abre la hoja de - Play con "1 mes gratis", la compra queda reconocida (no se reembolsa a los 3 - dias), `queryPurchasesAsync` restaura al reabrir, cancelar deja el estado - `CANCELED`, y el modo avion no rompe la app (error y reintento). - -## Verificacion en servidor (fase 2, contrato ya definido) - -**Por que.** Un cliente se puede modificar: puede mentir sobre si tiene -suscripcion. El cliente **no puede** ser la fuente de verdad del entitlement. Por -eso la validacion real la hace el servidor contra la Google Play Developer API. - -**Como debe ser.** El servidor valida el `purchaseToken` con la Android Publisher -API usando una service account, guarda el estado por dispositivo/cuenta y recibe -los cambios de ciclo de vida por Real-time Developer Notifications (RTDN) en un -Pub/Sub push. Devuelve un **token de entitlement firmado y con caducidad** que la -app cachea para funcionar offline (ver ventana de revalidacion en `PLAN.md`). - -### Contrato propuesto - -`POST /v1/billing/verify` (hoy responde `501`) - -```json -// request -{ "productId": "me.shirobyte42.glosso.pro", "purchaseToken": "...", - "packageName": "me.shirobyte42.glosso", "deviceId": "anon-install-id" } - -// response -{ "entitled": true, "status": "TRIAL", - "entitlementToken": "signed.jwt", "expiresAtMillis": 0 } -``` - -`POST /v1/billing/rtdn` (hoy responde `501`): recibe el sobre de Pub/Sub -(`PubSubPushEnvelope`), decodifica su `data` base64 a `DeveloperNotification` y -actualiza el entitlement (renovado, cancelado, en gracia, en espera, revocado, -cambio de precio). - -Los tipos viven en `server/.../api/Dto.kt` (`BillingVerifyRequest`, -`BillingVerifyResponse`, `PubSubPushEnvelope`, `PubSubMessage`) y el hueco del -cliente en `GlossoApi.verifyPurchase(...)`. - -### Que hace falta para activarlo - -1. Cuenta de servicio en Google Cloud con acceso a la Play Developer API. -2. En Play Console: Usuarios y permisos > invitar la service account con permiso - de finanzas/administracion de pedidos. -3. Habilitar la Android Publisher API en el proyecto de Google Cloud. -4. Topic de Pub/Sub + suscripcion push apuntando a `POST /v1/billing/rtdn`, y - configurarla en Play Console (Monetizacion > Notificaciones). -5. Secretos en el VPS: clave de la service account, nombre del package, secreto - del webhook. Nunca en git. - -**TODO** en `server/.../Application.kt` (`/billing/verify` y `/billing/rtdn`): no -implementar hasta tener la service account; el contrato y el porque ya quedan -escritos. - -## Checklist antes de considerar el billing listo - -- [ ] App creada en Play Console con el package correcto. -- [ ] Suscripcion + base plan + oferta de 1 mes gratis activos. -- [ ] License testers y track de pruebas internas configurados. -- [ ] Compra real probada en dispositivo con build de track y cuenta tester. -- [ ] Reconciliacion y reembolso comprobados en un pedido de prueba. -- [ ] Verificacion en servidor (`/billing/verify`) y RTDN (`/billing/rtdn`) - implementados con service account. -- [ ] Gate del cliente reemplazado por el entitlement firmado del servidor. +The current `/v1/config` and `/v1/me` shapes are in `docs/PRODUCT-CONFIG-CONTRACT.md`. diff --git a/docs/CONTENT-ENGINE.md b/docs/CONTENT-ENGINE.md index f1229e9..b6cce64 100644 --- a/docs/CONTENT-ENGINE.md +++ b/docs/CONTENT-ENGINE.md @@ -161,12 +161,11 @@ Por cada pieza del pool, medir: El ejercicio que funciona **sube**; el que no mejora al alumno, **baja o se descarta**. -## 10. Control de coste por tier +## 10. Cost control -- **Gratis**: pool existente + contenido cacheado. Cero generacion nueva. -- **Pro**: pool + personalizacion + long tail + generacion nueva si hace falta. - -Asi no se gasta GPU generando contenido exclusivo para quien no paga. +Glosso is free for everyone, so there is no tier to price against. Cost is +controlled by demand instead: the existing pool and cached content first, and new +generation only when the measured gap for a Need Class justifies it. ## 11. Economia (numeros medidos en el proyecto) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 21b2eec..726cd8a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -75,16 +75,6 @@ sudo systemctl enable --now glosso-server | `GLOSSO_DB_PASSWORD` | (vacio) | Password de la base de datos | | `GLOSSO_ANALYTICS_RETENTION_DAYS` | `180` | Dias de retencion de eventos (se purga al arrancar) | | `GLOSSO_CORS_ORIGINS` | (vacio) | Lista de origenes permitidos (web). Vacio = sin CORS (app nativa no lo necesita) | -| `GLOSSO_PLAY_SERVICE_ACCOUNT_JSON` | (vacio) | Ruta al JSON de la cuenta de servicio de Play. Sin ella, verificacion de compras desactivada | -| `GLOSSO_PLAY_SERVICE_ACCOUNT_JSON_BASE64` | (vacio) | Alternativa al fichero: el JSON en base64 | -| `GLOSSO_PLAY_PACKAGE` | `me.shirobyte42.glosso` | Paquete de Play que se valida | -| `GLOSSO_PLAY_PRODUCT_ID` | `com.glosso.app.pro` | Suscripcion Pro que concede entitlement | -| `GLOSSO_PLAY_TRIAL_OFFER_ID` | (vacio) | Id de la oferta de prueba para reportar `TRIAL` (si no, `ACTIVE`) | -| `GLOSSO_BILLING_CACHE_TTL_SECONDS` | `600` | TTL de la cache de verificaciones (0 = sin cache) | -| `GLOSSO_RTDN_SECRET` | (vacio) | Secreto compartido que Pub/Sub presenta en `/v1/billing/rtdn`. Sin el, webhook cerrado | -| `GLOSSO_RTDN_SUBSCRIPTION` | (vacio) | Subscription push esperada; si se define, se comprueba el sobre | - -Detalle y pasos exactos de Play/Pub/Sub: `docs/BILLING-E2E.md`. ## 6. Endpoints @@ -99,8 +89,6 @@ Detalle y pasos exactos de Play/Pub/Sub: `docs/BILLING-E2E.md`. | GET | `/v1/events/product` | Rollup de producto: notas, mastery, top errores (requiere token) | | GET | `/v1/content/manifest?language=` | Catalogo descargable del idioma (por defecto `en`) | | GET | `/v1/content/{kind}?language=` | Contenido del idioma (`DRILL`, `WORD`, `STORY`, `DIALOGUE`, `ARTICLE`, `EXERCISE`, `LESSON`) | -| POST | `/v1/billing/verify` | Valida un `purchaseToken` contra la Play Developer API; `503` si no hay cuenta de servicio | -| POST | `/v1/billing/rtdn` | Webhook de Real-time Developer Notifications (Pub/Sub), protegido por `GLOSSO_RTDN_SECRET` | | GET | `/admin/events` | Pagina HTML de analitica (requiere token) | | GET | `/stats` | Ocupacion del gate de inferencia (requiere token) | | GET | `/assets/content//{file}` | Ficheros estaticos por idioma (si `GLOSSO_ASSETS_DIR` existe) | @@ -203,13 +191,12 @@ vez, sin purgas manuales. `caddy`), asi que se edita, se sincroniza al VPS y se recarga sin reiniciar: `docker compose -f deploy/docker-compose.yml exec caddy caddy reload --config /etc/caddy/Caddyfile` (o `docker compose -f deploy/docker-compose.yml restart caddy`). -- Backups: de `GLOSSO_ASSETS_DIR` (assets re-generables) y, cuando existan, de la - base de datos de cuentas/entitlement. +- Backups: `GLOSSO_ASSETS_DIR` (regenerable assets) and the PostgreSQL database + (accounts, profiles, analytics). - Monitorizacion de CPU/RAM/ancho de banda desde el panel del proveedor. ## 9. Pendiente (fases siguientes) - Subida y reconocimiento de audio en el servidor (ONNX JVM). -- Cuentas, entitlement firmado y verificacion de Play Billing. - Rate limiting, Play Integrity, cola del LLM gratuito. - CDN para assets cuando crezca el trafico. diff --git a/docs/FDROID.md b/docs/FDROID.md new file mode 100644 index 0000000..4292274 --- /dev/null +++ b/docs/FDROID.md @@ -0,0 +1,108 @@ +# Glosso on F-Droid + +The Android app has two product flavors in `:androidApp`: + +| Flavor | For | Differences | +|---|---|---| +| `playstore` | Google Play | Sign in with Google ("Change phone"), through Credential Manager and Play services. Usage statistics on by default, with the onboarding notice. | +| `fdroid` | F-Droid | No Google library at all, so "Change phone" is hidden. Usage statistics off until the learner turns them on in Settings. | + +Everything else (lessons, cloud and on-device scoring, the model download and +import from a file) is the same in both. The build recipe for fdroiddata is +drafted in `metadata/me.shirobyte42.glosso.yml`; store texts are in +`fastlane/metadata/android//`. + +```sh +./gradlew :androidApp:assembleFdroidRelease # what F-Droid builds (unsigned) +./gradlew :androidApp:testFdroidDebugUnitTest +./gradlew :androidApp:dependencies --configuration fdroidReleaseRuntimeClasspath +``` + +## The F-Droid rules that apply, and how each is handled + +Read on 2026-11-10. + +**Non-free libraries.** The [Inclusion Policy](https://f-droid.org/docs/Inclusion_Policy/) +forbids "Google Play Services and Firebase and Crashlytics" and asks for "a build +flavour that does not require these dependencies". The scanner signatures +([suss.json](https://fdroid.gitlab.io/fdroid-suss/suss.json)) match +`play-services`, `com.google.android.libraries` (googleid), +`androidx.credentials:credentials-play-services-auth`, Firebase and +`com.android.billingclient`. Handling: Credential Manager, its Play services +provider and googleid are `playstoreImplementation` dependencies only, and their +code lives in `androidApp/src/playstore`. `:shared` has no Play dependency; the +sign-in seam is `accountSignIn`, which defaults to `UnavailableSignIn` and is +replaced only by the playstore flavor (`Flavor.install()`). The scanner is +flavor-aware (it only reads `implementation`/`fdroidImplementation` lines for +`gradle: [fdroid]`), so the recipe needs no `prebuild` stripping. +`FdroidFlavorTest` fails if any of those classes is on the fdroid classpath. + +**APK signing block.** `fdroid scanner` flags the dependency metadata block AGP +encrypts for Google Play. `dependenciesInfo.includeInApk = false` leaves it out of +APKs; bundles for Play keep it. + +**Network services.** [Anti-Features](https://f-droid.org/docs/Anti-Features/): +`NonFreeNet` is for "a proprietary network service"; the Glosso server is AGPL +and in this repository, so it does not apply. `TetheredNet` applies to an app +that "depends entirely on a service which is impossible (or not easy) to +replace", unless there is "a simple configuration option that allows pointing the +app to a running instance of an alternative, publicly available, self-hostable +server". The API host is only a build input (`-PglossoApiBase`), so the recipe +declares `TetheredNet`. An in-app server setting would remove it (owner decision). + +**Downloading the model and content.** The Inclusion Policy only restricts +downloading "additional executable binary files ... without explicit user +consent". The ONNX model (Apache-2.0) and the lessons are data, downloaded only +when the learner asks. `NonFreeAssets` applies to assets under non-commercial or +no-derivatives licences: the model is Apache-2.0; the lesson texts and audio +(authored in `glosso-data`) need a stated free licence, otherwise a reviewer may +apply it (owner decision). The model can also be imported from a local file +(below), so a phone never has to reach the server for it. + +**Tracking.** Anti-Features: not applied when the feature is "opt-in (i.e. you +are asked before it happens) and disabled by default", with informed consent and +a privacy policy. The usage statistics and crash traces are first-party and +consent based, so the fdroid flavor starts with them off +(`USAGE_STATISTICS_DEFAULT = false`, `Analytics.defaultEnabled`) and onboarding +says so (`onboarding_analytics_notice_off`). The privacy policy is in the app. + +**Prebuilt binaries.** "All binary dependencies including JAR files must originate +either from source compilation" or trusted Maven repositories (Inclusion Policy). +The only binary in git is `gradle/wrapper/gradle-wrapper.jar`, which the scanner +removes on its own. Repositories are only `google()`, `mavenCentral()` and the +Gradle plugin portal. The model, vocabulary and content are gitignored. + +**Self-updater.** None. The server's version policy is only a dismissible notice +with no link and no download. + +**Signing and reproducible builds.** Optional +([Reproducible Builds](https://f-droid.org/docs/Reproducible_Builds/)). Without +them F-Droid signs with its own key, so the F-Droid and Play installs cannot +update each other. Publishing developer-signed APKs (`Binaries` + +`AllowedAPKSigningKeys`) is possible later but hard to switch to after the first +release (owner decision). + +**Metadata.** [Descriptions and graphics](https://f-droid.org/docs/All_About_Descriptions_Graphics_and_Screenshots/): +`fastlane/metadata/android//` with `title.txt` (max 50), +`short_description.txt` (max 80), `full_description.txt` (max 4000) and +`changelogs/.txt` (max 500). F-Droid reads them from the tagged +release. The recipe follows the +[Build Metadata Reference](https://f-droid.org/docs/Build_Metadata_Reference/) and +the [fdroiddata CONTRIBUTING](https://gitlab.com/fdroid/fdroiddata/-/blob/master/CONTRIBUTING.md) +(`AutoUpdateMode: Version`, `UpdateCheckMode: Tags`); `fdroid lint` and +`fdroid readmeta` pass on it with fdroiddata's `config/`. + +## Importing the scoring model from a file + +Wherever the model download is offered (Settings, Offline, onboarding), "Import +from a file" opens the system file picker (Storage Access Framework, no storage +permission). The learner picks the model (`wav2vec2_phoneme_int8_dyn_pertensor.onnx`) +and `phoneme_vocab.json`, together or one at a time. Each file is copied into +the app's private storage while its SHA-256 is computed and is installed only +if it matches the digest pinned in `OnDeviceScorer` (the released pack) or the +one the server publishes (`.sha256`, the same check a download uses). +Names do not matter; anything else is deleted and reported. The files are the +ones `tools/fetch-assets.sh` downloads. + +Lesson packs are not importable: they are fetched per lesson from the content +manifest and have no file format of their own yet. diff --git a/docs/GOOGLE-SIGNIN.md b/docs/GOOGLE-SIGNIN.md index 9ba8ee8..3b5bc52 100644 --- a/docs/GOOGLE-SIGNIN.md +++ b/docs/GOOGLE-SIGNIN.md @@ -2,10 +2,9 @@ Como en Niagara Launcher: el usuario pulsa **Entrar con Google**, Android le muestra las cuentas de Google que ya tiene en el movil, elige una y entra. Sin -usuario, sin contrasena y sin formularios. Es **la unica forma** de que el -**Beta Founder y su PRO de por vida sigan a la persona, no al movil**: en el -movil nuevo entra con la misma cuenta y la cuenta se mueve alli (ver -`docs/BETA-RUNBOOK.md`, 6.1). Ya no hay recuperacion por codigo. +usuario, sin contrasena y sin formularios. It is the way a +learner's profile follows the person, not the phone: on the new phone they sign +in with the same account and the account moves there. Estado: **la implementacion esta lista; falta la configuracion de Google** (client id y huellas SHA-1). Sin ella la app oculta "Cambiar de movil" y el @@ -29,8 +28,7 @@ servidor responde `503` en `/v1/auth/google*`; todo lo demas funciona igual. - `exp` no vencido; - `nonce` emitido para **este** `X-Device-Id` y aun fresco: un token filtrado solo puede mover la cuenta al dispositivo del nonce, nunca a otro. Conocer - ese `X-Device-Id` ya equivale a ser ese dispositivo (modelo de amenaza en - `docs/BETA-RUNBOOK.md`). + ese `X-Device-Id` ya equivale a ser ese dispositivo. 4. Primera vez: vincula el `sub` de Google a la cuenta de este movil (`glosso.linked_accounts`). En otro movil: la cuenta se mueve alli y lo que ese movil tuviera se fusiona. Respuesta: el mismo `MeState` que `/v1/me`. @@ -92,14 +90,14 @@ Google Cloud del proyecto de Play (o uno nuevo del mismo propietario): ## Verificar - Tests: `./gradlew :server:test` (`GoogleSignInRouteTest`, - `EntitlementServiceGoogleTest`, `NimbusGoogleIdTokenVerifierTest`, - `JdbcEntitlementStorePostgresTest`) y `./gradlew :androidApp:testDebugUnitTest` - (`GlossoApiGoogleSignInTest`, `FounderRecoveryCopyTest`). + `AccountServiceGoogleTest`, `NimbusGoogleIdTokenVerifierTest`, + `JdbcAccountStorePostgresTest`) y `./gradlew :androidApp:testPlaystoreDebugUnitTest` + (`GlossoApiGoogleSignInTest`). - E2E real (solo con la configuracion hecha): en un emulador con una cuenta de Google anadida, Ajustes > Cuenta > Cambiar de movil > Entrar con Google, elegir la cuenta -> "Listo...". En otro emulador, lo mismo con la misma cuenta -> - `GET /v1/me` del segundo muestra `betaFounder=true` y el primero pasa a ser un - usuario anonimo nuevo. + `GET /v1/me` on the second one lists `google` in `linkedAccounts` and the first + one becomes a new anonymous user. - Errores tipicos: `NoCredentialException` / "No hay ninguna cuenta de Google" sin cuentas en el movil; `[28444] Developer console is not set up correctly` si falta el client id Android con el SHA-1 de la firma instalada, o el diff --git a/docs/IOS-DEV.md b/docs/IOS-DEV.md index 45535cd..55f7c48 100644 --- a/docs/IOS-DEV.md +++ b/docs/IOS-DEV.md @@ -74,7 +74,7 @@ Se pasan en el entorno de lanzamiento con el prefijo `SIMCTL_CHILD_`; una build - Analitica y crash reporter **apagados** (fase 6): `Analytics` nunca se inicializa, Ajustes no muestra la seccion de estadisticas y los ganchos de primer/segundo plano no registran ni envian - nada. No hay compras (`NoSubscriptions`) ni inicio de sesion (fase 5). + nada. No sign-in yet (phase 5). - Puntuacion solo en la nube (`NoOnDeviceScoring`); las pantallas no ofrecen el modelo. - Descargas de lecciones en primer plano (`ForegroundDownloader`); faltan las de `URLSession` en segundo plano. diff --git a/docs/LICENSING.md b/docs/LICENSING.md index 026ce84..182d59e 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -181,17 +181,18 @@ completa viaja en el APK: - `shared/src/commonMain/composeResources/files/licenses.json`: cada componente con nombre, autor, licencia y texto. Cubre las dependencias del **runtime classpath resuelto** - (no solo lo declarado a mano): Play Services/Firebase entran por la billing - library y se distribuyen, luego se atribuyen. Las 4 fuentes llevan **su propia + (no solo lo declarado a mano): Play Services arrives transitively through + Google sign-in and ships, so it is attributed. Las 4 fuentes llevan **su propia OFL** (cada una con su copyright). - `shared/src/commonMain/composeResources/files/privacy/privacy-.md`: politica de privacidad por idioma de interfaz (en/es/fr/de); `PrivacyScreen` elige la activa. - `tools/licensing/collect.py` genera ambos; `collect_test.py` falla si un artefacto del runtime no tiene entrada o si el asset se desincroniza. -- El classpath se resuelve con `:androidApp:licensesRuntimeClasspath` (lock - versionado en `tools/licensing/runtime-classpath.txt`); `preBuild` depende de - `:androidApp:licensesRuntimeClasspathCheck`, asi que un lock desactualizado - rompe el build. +- El classpath se resuelve con `:androidApp:licensesRuntimeClasspath` (un lock + versionado por sabor: `tools/licensing/runtime-classpath-fdroid.txt` y + `-playstore.txt`; el catalogo cubre la union). El `preBuild` de cada sabor + depende de su `licensesRuntimeClasspathCheck`, asi que un lock + desactualizado rompe el build. --- diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 4b5236d..5962386 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,4 +1,4 @@ -# Privacidad y analitica - Glosso Pro +# Privacidad y analitica - Glosso Resumen de que datos trata el producto y como declararlo en Google Play. No es asesoramiento legal. @@ -32,7 +32,7 @@ El servidor **no almacena la IP** en la tabla de eventos. Los eventos de servido ## Cuenta: entrar con Google (opcional) -Sirve para llevarse la cuenta (y el estatus Beta Founder) a un movil nuevo. Es +Sirve para llevarse la cuenta a un movil nuevo. Es opcional; sin ello la app funciona igual. - **Entrar con Google**: el usuario elige una cuenta de Google del movil (Android @@ -83,8 +83,7 @@ opcional; sin ello la app funciona igual. empezado y terminado, leccion empezada, terminada o abandonada, ejercicio terminado (tipo, franja de nota de 10 puntos, dominado o no), intento de pronunciacion puntuado en el movil (franja de nota, dominado o no, ids de las - reglas de error), permiso de microfono denegado, muro de pago visto, boton de - Pro pulsado y compra empezada o fallida. + reglas de error) y permiso de microfono denegado. Desde la app 2.5.4, la app manda ademas: diff --git a/docs/PRODUCT-CONFIG-CONTRACT.md b/docs/PRODUCT-CONFIG-CONTRACT.md index eabbb4a..acb7bb1 100644 --- a/docs/PRODUCT-CONFIG-CONTRACT.md +++ b/docs/PRODUCT-CONFIG-CONTRACT.md @@ -1,194 +1,63 @@ -# Contrato de producto: config, planes, entitlements y flags +# Product contract: config, identity and flags -Documento CONGELADO. Los tres streams implementan ESTE contrato y no lo cambian por su cuenta. -Si algo no encaja, se avisa al orquestador en vez de improvisar. +## 1. Authority -## 1. Autoridad +The backend is the authority for feature flags and the client version policy. The client never decides or +writes any of it. Glosso is free for everyone: there are no plans, prices, entitlements or phases (see +`docs/BILLING.md`). -El backend es la autoridad de: fase del producto, fecha de beta, plan, entitlements, Beta Founder, -feature flags y version minima. El cliente NO decide nada de esto y nunca lo escribe. +## 2. User identity -## 2. Identidad del usuario (decision congelada) +The identity is the DEVICE: -Hoy no hay cuentas. La identidad es el DISPOSITIVO: +- The app generates a UUID on first launch and persists it (`glosso_device_id`). +- The app sends it on every relevant call as the `X-Device-Id` header. +- The server keeps `glosso.users (device_id unique, created_at)`. The first call creates the user. +- Sign in with Google links an account to that user and moves it to a new phone (`docs/GOOGLE-SIGNIN.md`). -- La app genera un UUID en el primer arranque y lo persiste en SharedPreferences (`glosso_device_id`). -- La app lo manda en TODAS las llamadas relevantes como cabecera `X-Device-Id`. -- El servidor ya tiene `glosso.users (device_id unique, created_at)`. La primera vez = alta de usuario. -- Cuentas de verdad (email/OAuth) llegan despues; cuando lleguen, se VINCULAN al usuario existente - y el `beta_founder` se hereda. Disenar la tabla para que ese vinculo sea posible (columna `account_id` nullable). - -## 3. GET /v1/config (respuesta exacta) +## 3. GET /v1/config ```json { "schema": 1, "mode": "BETA", - "beta": { - "active": true, - "endDate": "2027-12-31T23:59:59Z", - "founderEnabled": true, - "founderBenefit": { "kind": "PRO_LIFETIME", "value": null, "until": null, "label": "Glosso Pro gratis para siempre" }, - "founderUntil": "2027-12-31T23:59:59Z" - }, - "plans": { - "FREE": { "pronunciation_analysis": "LIMITED", "exercises": "LIMITED", "analytics": "BASIC", "personalization": "NONE", "personalized_learning": false }, - "PRO": { "pronunciation_analysis": "UNLIMITED", "exercises": "EXTENDED", "analytics": "ADVANCED", "personalization": "NONE", "personalized_learning": false }, - "PERSONALIZED": { "pronunciation_analysis": "UNLIMITED", "exercises": "UNLIMITED", "analytics": "ADVANCED", "personalization": "FULL", "personalized_learning": true } - }, - "limits": { - "FREE": { "pronunciation_analysis_per_day": 20, "history_days": 30 } - }, - "pricing": { - "PRO": { "monthly": null, "yearly": null, "currency": null }, - "PERSONALIZED": { "monthly": null, "yearly": null, "currency": null } - }, - "flags": { - "beta_banner": true, "pro": false, "personalized": false, "accounts": false, - "adaptive_exercises": false, "advanced_analytics": false - }, + "beta": { "active": true }, + "flags": { "accounts": false }, "client": { "minimumSupportedVersion": 2210, "recommendedVersion": 2219, "forceUpdate": false } } ``` -Reglas del contrato: -- `mode` es la fase activa: `BETA` | `FREE`. `PRO`/`PERSONALIZED` son planes, no fases; un usuario puede ser - PRO estando el producto en fase FREE. -- `pricing` con `null` = precio no decidido: la UI NO muestra precio y NO vende. Nunca inventar cifras. -- `flags` sin clave = `false`. Un flag nuevo no requiere release de app. -- `limits` es opcional por plan; lo que no este, no se limita. -- `endDate` es la UNICA fuente de la fecha. En la app solo puede existir como texto formateado desde este valor. -- Toda la config se sirve con `Cache-Control: no-cache` + `ETag`, patron identico a `/v1/content/manifest`. - -## 4. Entitlements y validacion server-side - -- Tabla nueva `glosso.entitlements`: `user_id`, `plan` (FREE/PRO/PERSONALIZED), `status` - (NONE/ACTIVE/GRACE/CANCELED/PENDING/TRIAL), `source` (beta/pro/play/manual), `expires_at`, `started_at`, - `auto_renew`, `provider`, `provider_token_hash`, `updated_at`. -- El plan efectivo de un usuario lo calcula SIEMPRE el servidor y lo expone en `GET /v1/me` (autenticado por - `X-Device-Id`): `{ "plan", "status", "betaFounder", "betaFounderSince", "betaFounderBenefit", "expiresAt" }`. -- El cliente cachea esa respuesta y la usa para la UI, pero la decision final de acceso a contenido - premium es del servidor. Un APK viejo con `isPro=true` local no habilita nada. -- Beta Founder: se marca en `glosso.users` si la fecha de alta es <= `beta.founderUntil` (enmienda 2, - seccion 8; antes era `beta_end_date`), y es PERMANENTE (columna `beta_founder boolean`, `beta_founder_since`, `beta_founder_until`, `beta_founder_benefit`). - Nunca se borra ni se recalcula a la baja. - -## 5. Transicion beta -> produccion (reglas) - -- Al pasar `beta_end_date`, `mode` cambia a `FREE` por configuracion. NO se bloquea a nadie, NO se cobra solo. -- Usuario nuevo despues de la beta: `FREE`. -- Beta Founder: `PRO` (contenido) gratis, **sin `PERSONALIZED`**. El estatus se mantiene aunque cambie - de plan; nunca concede el plan superior. -- Usuario con suscripcion Play valida: `PRO`. Con personalizacion contratada: `PERSONALIZED`. -- Nunca se borran cuentas, progreso ni historial de pronunciacion. La migracion es solo de plan. - -## 6. Config centralizada (una sola fuente) - -Variables de entorno del servidor (nombres congelados), leidas UNA vez en `ProductConfig.fromEnv()`: -`GLOSSO_PRODUCT_MODE`, `GLOSSO_BETA_END_DATE`, `GLOSSO_BETA_FOUNDER_ENABLED`, -`GLOSSO_BETA_FOUNDER_BENEFIT_KIND`, `GLOSSO_BETA_FOUNDER_BENEFIT_VALUE`, -`GLOSSO_MIN_CLIENT_VERSION`, `GLOSSO_RECOMMENDED_CLIENT_VERSION`, -`GLOSSO_LIMITS_JSON`, `GLOSSO_PRICING_JSON` -y `GLOSSO_FLAG_` para cada feature flag (por ejemplo `GLOSSO_FLAG_PRO=false`). -`endDate` en ISO-8601 UTC. `GLOSSO_LIMITS_JSON` y `GLOSSO_PRICING_JSON` llevan el JSON crudo de -`limits` y `pricing` (null si faltan o no parsean). Valores por defecto seguros en codigo si falta -la variable. - -## 7. Enmienda 1: tiers, estatus y lo que NO se decide hoy - -- Son TRES planes: `FREE`, `PRO`, `PERSONALIZED`. El plan superior de suscripcion es `PERSONALIZED` - (la app completa, que se adapta al usuario). No hay cuarto plan. -- `Beta Founder` NO es un plan: es un ESTATUS ortogonal al plan. En `/v1/me` son DOS campos - independientes (`plan`, `betaFounder`); nunca se anidan ni se mezclan. El estatus se conserva aunque - cambie de plan. -- A efectos de acceso, un Beta Founder recibe el plan `PRO` (contenido) gratis: el servidor reporta - `plan=PRO` mientras no tenga un plan superior contratado. El estatus de founder NUNCA concede - `PERSONALIZED`, ni siquiera cuando ese plan exista. -- Que funcionalidad concreta entra en cada plan NO se decide ahora. `limits`, `pricing` y - `beta.founderBenefit.value` van a `null` (= por decidir) hasta que la beta diga que se usa de verdad. - La arquitectura debe permitir fijarlos desde configuracion, sin release de app. -- El plan `PERSONALIZED` se construira sobre datos reales de uso de la beta, no sobre una lista de - funcionalidades inventada. No implementar entrenamiento adaptativo ficticio para "llenar" el plan. - -## 8. Enmienda 2: beta hasta 2027, Beta Founder = PRO de por vida, fase por fecha - -Decisiones del dueno (2026-09-25). Sustituyen lo que contradigan de las secciones anteriores (fechas 2026, -`discount_percent` como beneficio, founder marcado por `beta_end_date`). Todo lo servido es ADITIVO: ningun -campo existente cambia de nombre ni de tipo, `schema` sigue en `1` y una app que ignora claves desconocidas -no se rompe. - -### 8.1 Dos fechas, dos claves independientes - -- `beta.endDate` (`GLOSSO_BETA_END_DATE`, defecto `2027-12-31T23:59:59Z`): fin de la beta GRATUITA. Hasta - entonces TODOS los usuarios, fundadores o no, tienen acceso completo y gratis (incluido lo que luego sera - `PERSONALIZED`), sin anuncios. Copy: "Gratis hasta el 31 de diciembre de 2027." -- `beta.founderUntil` (`GLOSSO_BETA_FOUNDER_UNTIL`, defecto `2027-12-31T23:59:59Z`): fin de la ventana de - Beta Founder. Toda alta <= `founderUntil` (con `founderEnabled`) es fundador, para siempre. Beta Founder = - todo el que use la app durante la beta. -- Comparten valor por defecto pero son DOS claves: ninguna controla a la otra y el codigo no las fusiona. - Solo hay defaults seguros en `ProductConfig`; el valor real se lee de config. - -### 8.2 La fase sigue el calendario - -- `mode` servido = `BETA` solo si `GLOSSO_PRODUCT_MODE=BETA` y ahora <= `endDate`; si no, `FREE`. - `beta.active` igual. `flags.beta_banner`, si no se fija explicitamente, sigue a `beta.active`. -- La beta se cierra sola al pasar la fecha, sin reiniciar (el ETag cambia). `GLOSSO_PRODUCT_MODE=FREE` la - cierra antes. `mode` sigue siendo solo `BETA` | `FREE`: `PRO` y `PERSONALIZED` son planes (seccion 3). - -### 8.3 Beta Founder = PRO gratis de por vida, como pago unico (no suscripcion) - -- El beneficio es `beta.founderBenefit.kind = "PRO_LIFETIME"` (`GLOSSO_BETA_FOUNDER_BENEFIT_KIND`, defecto): - acceso GRATUITO y PERMANENTE al plan `PRO`. NUNCA incluye `PERSONALIZED` ("Pro+"): ese se paga. Copy: - "Como Beta Founder tendras Glosso Pro gratis para siempre. El plan Personalizado no esta incluido." -- Se modela como **lifetime grant** (compra unica), no como suscripcion: tabla `glosso.lifetime_grants` - (`user_id`, `plan`, `source`, `provider`, `provider_ref_hash`, `granted_at`, `revoked_at`, - `revoke_reason`), sin expiracion, sin renovacion, sin periodo de gracia y sin RTDN. Solo se revoca a mano - por fraude explicito (`revoked_at`), y una revocacion no se deshace sola. -- El del fundador es un grant con `source = "beta_founder"`. La misma abstraccion sirve para un futuro - producto de pago unico (p.ej. "Pro de por vida" comprado: `source = "purchase"` + su proveedor). -- Se persiste al marcar al fundador (y la migracion V5 lo crea para los fundadores ya marcados). Cambiar - despues la config no se lo quita a nadie. Otro `kind` de beneficio no concede plan. -- `glosso.users.beta_founder_benefit` guarda el `kind` concedido (`PRO_LIFETIME`), no la etiqueta. -- `founderBenefit.value` y `founderBenefit.until` quedan en `null`: no hay porcentaje y no caduca. - `GLOSSO_BETA_FOUNDER_BENEFIT_LABEL` fija la etiqueta. -- Esto sustituye a la regla de la enmienda 1 "el servidor reporta `plan=PRO` para un founder": ahora ese - PRO sale del grant de por vida. - -### 8.4 Plan efectivo: suscripcion y pago unico son ortogonales - -- Plan efectivo = el mayor entre la suscripcion en vigor (`glosso.entitlements`, Play Billing) y los grants - de por vida no revocados. `FREE` < `PRO` < `PERSONALIZED`. -- Founder que ademas se suscribe a `PERSONALIZED` -> `PERSONALIZED`; si la suscripcion caduca vuelve a su - `PRO` de por vida, nunca a `FREE`. Founder que se suscribe a `PRO` sigue `PRO` (gana el de por vida). - Nunca pierde la condicion de fundador. -- Transicion post-beta: usuario nuevo -> `FREE`; Beta Founder -> `PRO` gratis de por vida (sin - `PERSONALIZED`); suscriptor Pro -> `PRO`; suscriptor Personalized -> `PERSONALIZED`. Nunca se borran - datos, historial ni estado. - -### 8.5 `GET /v1/me` anade (todos opcionales para el cliente) - -- `planSource`: `LIFETIME` | `SUBSCRIPTION` | `FREE`. Con `LIFETIME` la UI muestra "acceso permanente" y - NUNCA renovacion ni cancelacion. `status` y `expiresAt` describen el plan solo si es `SUBSCRIPTION`; con - `LIFETIME` van `NONE` y `null`. -- `lifetimeGrants`: `[{ "plan", "source", "grantedAt" }]`. -- `subscription`: `{ status, provider, source, startedAt, expiresAt, autoRenew }` de la suscripcion en - vigor, o `null`. `CANCELED` + `autoRenew=false` = cancelada pero pagada hasta `expiresAt`; `GRACE` = - renovacion fallida aun con acceso. Restaurar en otro dispositivo es un nuevo `POST /v1/billing/verify`. -- `betaAccess`: `true` mientras la beta gratuita esta abierta. -- `entitlements`: lo que el servidor concede AHORA. En beta, la fila de `PERSONALIZED` (acceso completo); - despues, la fila de `plan`. Los `flags` siguen decidiendo que funcionalidades existen. -- `betaFounderUntil`: reservado; hoy `null` (la ventaja no caduca). -- `linkedAccounts`: proveedores externos vinculados a la cuenta (hoy solo `google`), nunca el identificador. - Si la cuenta se fue a otro movil, este dispositivo es un usuario anonimo nuevo y llega `[]`: la app olvida - su estado local de "vinculada a ...". - -### 8.6 Entitlements por plan, flags y version - -- `GLOSSO_PLANS_JSON` sobrescribe la matriz de `plans` clave a clave sobre los defaults (cambiar valores, - anadir claves; ninguna clave por defecto desaparece; solo existen los tres planes). Clave nueva - `personalized_learning` (bool; `true` solo en `PERSONALIZED` por defecto). -- `GLOSSO_FORCE_UPDATE` (defecto `false`); `recommendedVersion` nunca queda por debajo de - `minimumSupportedVersion`. Durante la beta no se fuerza a nadie. -- Una variable vacia cuenta como no definida (`docker compose` inyecta `""`). -- Variables nuevas (se suman a la seccion 6): `GLOSSO_BETA_FOUNDER_UNTIL`, - `GLOSSO_BETA_FOUNDER_BENEFIT_LABEL`, `GLOSSO_FORCE_UPDATE`, `GLOSSO_PLANS_JSON`. +Rules: + +- `mode` and `beta` are fixed legacy keys. App builds that still contain the paid tier unlock every lesson + only while they read an open beta (`mode = "BETA"` or `beta.active = true`), so the server always serves + exactly these values. Never serve `"FREE"` or `"active": false`. +- `flags` without a key = `false`. A new flag needs no app release. +- `client` is the version policy. The app only shows a soft, dismissible notice; nothing blocks. + `recommendedVersion` never sits below `minimumSupportedVersion`. +- Served with `Cache-Control: no-cache` + `ETag`, the same pattern as `/v1/content/manifest`. +- `schema` changes only on a breaking change; clients ignore unknown keys. + +## 4. GET /v1/me + +Authenticated by `X-Device-Id`; `400` without it, `503` without a database. + +```json +{ "linkedAccounts": ["google"] } +``` + +- `linkedAccounts`: the external providers linked to the account (today only `google`), never the + identifier. If the account moved to another phone, this device is a new anonymous user and gets `[]`, so + the app forgets its local "linked to ..." state. +- `POST /v1/auth/google` answers the same shape. +- Served with `Cache-Control: no-store`. + +## 5. Configuration + +Server environment variables, read once in `ProductConfig.fromEnv()`: + +- `GLOSSO_MIN_CLIENT_VERSION` (default `2210`), `GLOSSO_RECOMMENDED_CLIENT_VERSION` (default `2219`), + `GLOSSO_FORCE_UPDATE` (default `false`); +- `GLOSSO_FLAG_` for each feature flag, served as `flags.` in lower case. + +An empty variable counts as unset (`docker compose` injects `""`). diff --git a/docs/SCORING-DECISION-LAYER.md b/docs/SCORING-DECISION-LAYER.md index d79f77b..cc5cdda 100644 --- a/docs/SCORING-DECISION-LAYER.md +++ b/docs/SCORING-DECISION-LAYER.md @@ -166,5 +166,4 @@ ausencia de alineacion. ## Lo que no se toca -Identidad (`me.shirobyte42.glosso`), billing, `AccessPolicy`, la frontera A1 -gratis / A2 muro y el modelo acustico. Sin dependencias nuevas. +Identity (`me.shirobyte42.glosso`) and the acoustic model. No new dependencies. diff --git a/docs/TTS-LAYER.md b/docs/TTS-LAYER.md index 1cae30e..86f8e1a 100644 --- a/docs/TTS-LAYER.md +++ b/docs/TTS-LAYER.md @@ -16,18 +16,18 @@ Revisado el repo antes de escribir codigo. Lo que hay hoy: | Pieza | Estado real | |---|---| -| Backend | `:server` Ktor 3.0.3, un `Application.kt`. Rutas de scoring, contenido, billing, admin. | +| Backend | `:server` Ktor 3.0.3, un `Application.kt`. Rutas de scoring, contenido, admin. | | Content Pool | **No existe** como concepto del brief. El contenido publicado vive en SQLite `content.db` por idioma (`ContentDb.kt`), solo lectura, escrito por `tools/content/pipeline.py`. | | Generacion de contenido | Fase 1 (`feat/need-engine`, PR #61) cubre perfil -> necesidad -> need class -> demanda y ya esta **mergeada** en `main`. Fases 2/3 (Content Pool, cola Redis, worker) **no existen todavia**. | | Almacenamiento | **No hay R2/S3.** Solo el volumen de assets `/srv/glosso/assets` y la cache en disco del pipeline. | -| Configuracion | Variables de entorno leidas sueltas en `AnalyticsStore`, `BillingService`, etc. No habia `.env.example`. | +| Configuracion | Variables de entorno leidas sueltas en `AnalyticsStore`, etc. No habia `.env.example`. | | Worker / queue | **No existen.** Ni Redis, ni scheduler, ni streams. | | Redis | **No existe** (ni dependencia, ni compose). | | Abstraccion TTS previa | **No existe.** `elevenlabs`/`cartesia` solo aparecian en documentacion. El audio offline lo hace Kokoro/Chatterbox en `tools/content/pipeline.py`. | Conclusion: no habia una abstraccion que reutilizar. Se implementa una nueva, aislada en `com.glosso.server.tts`, sin tocar el plano de senal -(`/v1/score`, `/v1/content/*`, `/assets`), billing ni `:engine`. +(`/v1/score`, `/v1/content/*`, `/assets`) ni `:engine`. --- diff --git a/fastlane/metadata/android/de-DE/changelogs/2226.txt b/fastlane/metadata/android/de-DE/changelogs/2226.txt new file mode 100644 index 0000000..515777a --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/2226.txt @@ -0,0 +1,4 @@ +Erste Version auf F-Droid. +- Ohne Google-Bibliotheken: keine Play-Dienste und keine Google-Anmeldung („Handy wechseln" ist nicht verfügbar). +- Nutzungsstatistiken sind zunächst aus; du kannst sie in den Einstellungen einschalten. +- Importiere das Offline-Bewertungsmodell aus einer Datei, vor der Installation geprüft. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 0000000..110c0da --- /dev/null +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,18 @@ +Glosso ist ein Sprachkurs, der deine Aussprache wirklich misst. Er hört jeden Versuch, erkennt die Laute, die du tatsächlich gesprochen hast, vergleicht sie mit den erwarteten und sagt dir genau, welchen Laut du verbessern solltest. + +Sprachen: Englisch (britisch und amerikanisch), Französisch, Deutsch und Russisch. + +Was du bekommst +
    +
  • Lektionen mit Dialogen, Geschichten, Wortschatz und Übungen, mit Audio.
  • +
  • Rückmeldung zur Aussprache für jeden Laut: perfekt, knapp oder verfehlt, mit deinem Laut neben dem erwarteten.
  • +
  • Verteilte Wiederholung der Wörter und Laute, die dir am schwersten fallen.
  • +
  • Optionale Offline-Bewertung: Lade das Modell herunter (etwa 350 MB) oder importiere es aus einer Datei, dann wird jeder Versuch auf dem Handy mit derselben Engine bewertet, die auch der Server nutzt.
  • +
  • Lektionen herunterladen und ohne Verbindung lernen.
  • +
+ +Kostenlos und Open Source +Glosso ist für alle kostenlos, ohne Bezahlversion und ohne Werbung. Die App und ihr Server sind freie Software unter der GNU AGPL v3 oder neuer. Unterstützung ist eine freiwillige Spende. + +Netzwerk und Datenschutz +Die Lektionen und, ohne das Offline-Modell, die Bewertung der Aussprache kommen vom Glosso-Server, dessen Quellcode im selben Repository liegt und selbst gehostet werden kann. In dieser Version sind Nutzungsstatistiken aus, bis du sie in den Einstellungen einschaltest; dann gehen sie nur an den Glosso-Server, ohne SDKs von Dritten. Die F-Droid-Version enthält keine Google-Bibliotheken. diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 0000000..e91ca0d --- /dev/null +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Sprachkurs, der deine Aussprache Laut für Laut misst diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 0000000..94e10a0 --- /dev/null +++ b/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +Glosso diff --git a/fastlane/metadata/android/en-US/changelogs/2226.txt b/fastlane/metadata/android/en-US/changelogs/2226.txt new file mode 100644 index 0000000..da0fba4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2226.txt @@ -0,0 +1,4 @@ +First F-Droid release. +- Build without Google libraries: no Play services, no Google sign-in ("Change phone" is not available). +- Usage statistics start off; you can turn them on in Settings. +- Import the offline scoring model from a file, verified before it is installed. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..ae391a4 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,18 @@ +Glosso is a language course that measures your pronunciation for real. It listens to each take, recognizes the sounds you actually made, compares them with the expected ones and tells you exactly which sound to fix. + +Languages: English (British and American), French, German and Russian. + +What you get +
    +
  • Lessons with dialogues, stories, vocabulary and exercises, with native-speaker style audio.
  • +
  • Pronunciation feedback per sound: perfect, close or missed, with the sound you produced next to the one expected.
  • +
  • Spaced review of the words and sounds you find hardest.
  • +
  • Optional offline scoring: download the scoring model (about 350 MB) or import it from a file, and every take is scored on the phone with the same engine the server runs.
  • +
  • Download lessons to study with no connection.
  • +
+ +Free and open source +Glosso is free for everyone, with no paid tier and no ads. The app and its server are free software under the GNU AGPL v3 or later. Support is a voluntary donation. + +Network and privacy +Lessons and, without the offline model, pronunciation scoring come from the Glosso server, whose source code is in the same repository and can be self-hosted. Usage statistics are off in this build until you turn them on in Settings; when on, they go only to Glosso's own server, with no third-party SDKs. The F-Droid build contains no Google libraries. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..3c27856 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..9eff311 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..4bcd39d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..7754cac --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Language course that measures your pronunciation, sound by sound diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..94e10a0 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Glosso diff --git a/fastlane/metadata/android/es-ES/changelogs/2226.txt b/fastlane/metadata/android/es-ES/changelogs/2226.txt new file mode 100644 index 0000000..282de55 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/2226.txt @@ -0,0 +1,4 @@ +Primera versión en F-Droid. +- Sin bibliotecas de Google: sin servicios de Play ni inicio de sesión con Google ("Cambiar de móvil" no está disponible). +- Las estadísticas de uso empiezan desactivadas; puedes activarlas en Ajustes. +- Importa el modelo de puntuación sin conexión desde un archivo, verificado antes de instalarlo. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt new file mode 100644 index 0000000..41298f5 --- /dev/null +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -0,0 +1,18 @@ +Glosso es un curso de idiomas que mide tu pronunciación de verdad. Escucha cada intento, reconoce los sonidos que has hecho de verdad, los compara con los esperados y te dice exactamente qué sonido corregir. + +Idiomas: inglés (británico y americano), francés, alemán y ruso. + +Qué incluye +
    +
  • Lecciones con diálogos, historias, vocabulario y ejercicios, con audio.
  • +
  • Corrección de la pronunciación por sonido: perfecto, cerca o fallado, con el sonido que hiciste junto al esperado.
  • +
  • Repaso espaciado de las palabras y los sonidos que más te cuestan.
  • +
  • Puntuación sin conexión opcional: descarga el modelo (unos 350 MB) o impórtalo desde un archivo, y cada intento se puntúa en el móvil con el mismo motor que usa el servidor.
  • +
  • Descarga lecciones para estudiar sin conexión.
  • +
+ +Gratis y de código abierto +Glosso es gratis para todos, sin plan de pago y sin anuncios. La app y su servidor son software libre bajo la GNU AGPL v3 o posterior. El apoyo es una donación voluntaria. + +Red y privacidad +Las lecciones y, sin el modelo sin conexión, la puntuación de la pronunciación vienen del servidor de Glosso, cuyo código está en el mismo repositorio y se puede alojar por cuenta propia. En esta versión las estadísticas de uso están desactivadas hasta que las actives en Ajustes; si las activas, solo van al servidor de Glosso, sin SDK de terceros. La versión de F-Droid no contiene bibliotecas de Google. diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt new file mode 100644 index 0000000..2cc89d7 --- /dev/null +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -0,0 +1 @@ +Curso de idiomas que mide tu pronunciación, sonido a sonido diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt new file mode 100644 index 0000000..94e10a0 --- /dev/null +++ b/fastlane/metadata/android/es-ES/title.txt @@ -0,0 +1 @@ +Glosso diff --git a/fastlane/metadata/android/fr-FR/changelogs/2226.txt b/fastlane/metadata/android/fr-FR/changelogs/2226.txt new file mode 100644 index 0000000..8e37711 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/2226.txt @@ -0,0 +1,4 @@ +Première version sur F-Droid. +- Sans bibliothèques Google : pas de services Play ni de connexion Google (« Changer de téléphone » n'est pas disponible). +- Les statistiques d'utilisation sont désactivées au départ ; vous pouvez les activer dans les Réglages. +- Importez le modèle d'évaluation hors ligne depuis un fichier, vérifié avant installation. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 0000000..daeb33a --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,18 @@ +Glosso est un cours de langues qui mesure vraiment votre prononciation. Il écoute chaque essai, reconnaît les sons que vous avez réellement produits, les compare aux sons attendus et vous dit exactement quel son corriger. + +Langues : anglais (britannique et américain), français, allemand et russe. + +Ce que vous obtenez +
    +
  • Des leçons avec dialogues, histoires, vocabulaire et exercices, avec audio.
  • +
  • Une correction de la prononciation son par son : parfait, proche ou manqué, avec le son produit à côté du son attendu.
  • +
  • Une révision espacée des mots et des sons les plus difficiles pour vous.
  • +
  • Une évaluation hors ligne facultative : téléchargez le modèle (environ 350 Mo) ou importez-le depuis un fichier, et chaque essai est évalué sur le téléphone avec le même moteur que le serveur.
  • +
  • Des leçons téléchargeables pour étudier sans connexion.
  • +
+ +Gratuit et open source +Glosso est gratuit pour tous, sans offre payante et sans publicité. L'application et son serveur sont des logiciels libres sous GNU AGPL v3 ou ultérieure. Le soutien est un don volontaire. + +Réseau et vie privée +Les leçons et, sans le modèle hors ligne, l'évaluation de la prononciation viennent du serveur de Glosso, dont le code source est dans le même dépôt et peut être auto-hébergé. Dans cette version, les statistiques d'utilisation sont désactivées jusqu'à ce que vous les activiez dans les Réglages ; une fois activées, elles ne vont qu'au serveur de Glosso, sans SDK tiers. La version F-Droid ne contient aucune bibliothèque Google. diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 0000000..14c9783 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Cours de langues qui mesure votre prononciation, son par son diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 100644 index 0000000..94e10a0 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +Glosso diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75f7163..7bd7100 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,7 +56,6 @@ navigationevent-multiplatform = "1.0.1" # androidx icons the BOM already brings. compose-material3-multiplatform = "1.9.0" compose-material-icons-multiplatform = "1.7.3" -billing = "9.1.0" androidx-credentials = "1.5.0" googleid = "1.1.1" junit = "4.13.2" @@ -138,7 +137,6 @@ lifecycle-viewmodel-compose-multiplatform = { module = "org.jetbrains.androidx.l lifecycle-runtime-compose-multiplatform = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-multiplatform" } compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3-multiplatform" } compose-multiplatform-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-material-icons-multiplatform" } -billing = { module = "com.android.billingclient:billing", version.ref = "billing" } androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } diff --git a/metadata/me.shirobyte42.glosso.yml b/metadata/me.shirobyte42.glosso.yml new file mode 100644 index 0000000..62777c9 --- /dev/null +++ b/metadata/me.shirobyte42.glosso.yml @@ -0,0 +1,36 @@ +# Draft F-Droid build recipe, kept here as the reference for the merge request +# to https://gitlab.com/fdroid/fdroiddata (where it lives as +# metadata/me.shirobyte42.glosso.yml). See docs/FDROID.md for the reasoning +# behind every field. Before submitting: tag the release, put its full commit +# hash in `commit`, and check versionName/versionCode match androidApp/build.gradle.kts. +AntiFeatures: + TetheredNet: + en-US: Lessons and cloud pronunciation scoring come from the developer's server. + Its source is free (AGPL, in the same repository) and self-hostable, but the + app has no setting to point it at another instance. +Categories: + - Science & Education +License: AGPL-3.0-or-later +AuthorName: IgnacioLD +SourceCode: https://github.com/IgnacioLD/glosso +IssueTracker: https://github.com/IgnacioLD/glosso/issues + +AutoName: Glosso + +RepoType: git +Repo: https://github.com/IgnacioLD/glosso.git + +Builds: + - versionName: 2.5.4 + versionCode: 2226 + commit: v2.5.4 + subdir: androidApp + gradle: + - fdroid + gradleprops: + - glosso.ios=false + +AutoUpdateMode: Version +UpdateCheckMode: Tags +CurrentVersion: 2.5.4 +CurrentVersionCode: 2226 diff --git a/server/src/main/kotlin/com/glosso/server/Application.kt b/server/src/main/kotlin/com/glosso/server/Application.kt index 955bd5b..dedcaeb 100644 --- a/server/src/main/kotlin/com/glosso/server/Application.kt +++ b/server/src/main/kotlin/com/glosso/server/Application.kt @@ -109,20 +109,15 @@ import com.glosso.server.analytics.StackFrame import com.glosso.server.analytics.StoredCrashTrace import com.glosso.server.analytics.UmamiMirror import com.glosso.server.analytics.ProductSummary -import com.glosso.server.billing.BillingService -import com.glosso.server.billing.RtdnOutcome -import com.glosso.server.billing.VerifyOutcome import com.glosso.server.product.AccountChangedConcurrently import com.glosso.server.product.AccountSecret -import com.glosso.server.product.EntitlementService -import com.glosso.server.product.EntitlementStore +import com.glosso.server.product.AccountService +import com.glosso.server.product.AccountStore import com.glosso.server.product.GoogleIdTokenVerifier import com.glosso.server.product.GoogleSignInConfig import com.glosso.server.product.GoogleSignInResult -import com.glosso.server.product.JdbcEntitlementStore +import com.glosso.server.product.JdbcAccountStore import com.glosso.server.product.ProductConfig -import com.glosso.server.product.StoredEntitlement -import com.glosso.server.product.providerPlan import com.glosso.server.product.toResponse import com.glosso.server.content.ContentDb import com.glosso.server.api.AlignRequest @@ -131,8 +126,6 @@ import com.glosso.server.api.AnalyticsDeleteResponse import com.glosso.server.api.AnalyticsEventDto import com.glosso.server.api.AnalyticsIngestRequest import com.glosso.server.api.AnalyticsIngestResponse -import com.glosso.server.api.BillingVerifyRequest -import com.glosso.server.api.BillingVerifyResponse import com.glosso.server.api.CrashCauseDto import com.glosso.server.api.CrashTraceDto import com.glosso.server.api.CrashTraceResponse @@ -142,7 +135,6 @@ import com.glosso.server.api.ErrorResponse import com.glosso.server.api.HealthResponse import com.glosso.server.api.PhonemizeRequest import com.glosso.server.api.PhonemizeResponse -import com.glosso.server.api.PubSubPushEnvelope import com.glosso.server.api.ContentPoolItemDto import com.glosso.server.api.ContentPoolResponse import com.glosso.server.api.DemandAggregateResponse @@ -197,7 +189,7 @@ private val logger = KotlinLogging.logger("GlossoServer") * Failed credential checks by gate. Kept process-wide so the per-request guards * (which do not see the Prometheus registry) can report without threading it * through every call site. See A09 in docs/OWASP-BACKEND.md: a spike here is the - * signal that someone is guessing admin, ingest, client or RTDN credentials. + * signal that someone is guessing admin, ingest or client credentials. */ private val authFailureCounts = ConcurrentHashMap() @@ -625,7 +617,7 @@ fun Application.module(assetsDir: File) { controlStore = database?.controlStore, contentCatalog = catalog, controlDatabase = database, - entitlementStore = database?.let { JdbcEntitlementStore(it.dataSource) }, + accountStore = database?.let { JdbcAccountStore(it.dataSource) }, accountSecret = AccountSecret.fromEnv(), googleVerifier = GoogleSignInConfig.verifierFromEnv(), ) @@ -636,8 +628,6 @@ fun Application.module(assetsDir: File) { * level databases. Injected so tests can point at a temp directory. * @param adminToken guards /metrics and the analytics admin views. * @param analytics first-party event store; null disables analytics cleanly. - * @param billing Play purchase verification and RTDN; defaults to the - * environment-configured service so tests can inject a fake Play API. * @param apiKey shared client credential for the scoring oracle. When set, * `/v1/score` requires the `X-Api-Key` header; null leaves it open for * development builds and emulators (production sets `GLOSSO_API_KEY`). @@ -655,10 +645,11 @@ fun Application.module(assetsDir: File) { * component. Null disables generation cleanly. * @param generation assembled phase-2 runtime. Injected in tests; production * builds it when Redis and a TTS provider are configured. - * @param entitlementStore identity and entitlement persistence. Null disables - * `GET /v1/me` cleanly; `/v1/config` is served from [productConfig] regardless. + * @param accountStore device identity and linked accounts. Null disables + * `GET /v1/me` and Sign in with Google cleanly; `/v1/config` is served from + * [productConfig] regardless. * @param productConfig the product configuration served by `GET /v1/config`. - * Defaults to the environment; injected so tests can pin a phase and a date. + * Defaults to the environment; injected so tests can pin flags and versions. * @param accountSecret server secret for sign-in nonces; production reads * `GLOSSO_ACCOUNT_SECRET`. Null (the default, so a test never picks up the * developer's environment) disables Sign in with Google with a 503. @@ -671,7 +662,6 @@ fun Application.module( analytics: AnalyticsStore?, /** A dashboard copy of the stored events (Umami); null keeps Postgres only. */ mirror: AnalyticsMirror? = null, - billing: BillingService = BillingService.fromEnv(), apiKey: String? = null, ingestToken: String? = null, recognizer: PhonemeRecognizer? = null, @@ -679,7 +669,7 @@ fun Application.module( contentCatalog: ContentCatalog = EmptyContentCatalog, controlDatabase: ControlPlaneDatabase? = null, generation: GenerationRuntime? = null, - entitlementStore: EntitlementStore? = null, + accountStore: AccountStore? = null, productConfig: ProductConfig = ProductConfig.fromEnv(), multiAccent: MultiAccentConfig = MultiAccentConfig.fromEnv(), voiceIngest: VoiceIngestService? = null, @@ -693,10 +683,9 @@ fun Application.module( ) logger.info { "Multi-accent scoring: ${if (multiAccent.enabled) "on" else "off"}; voice fetcher=${voices.fetcher.name}" } val bootstrap = System.getenv("GLOSSO_BOOTSTRAP") == "1" - val entitlementService = entitlementStore?.let { - EntitlementService( + val accountService = accountStore?.let { + AccountService( it, - productConfig, accountSecret = accountSecret, googleVerifier = googleVerifier, ) @@ -865,7 +854,7 @@ fun Application.module( val serverErrors = prometheus.counter("glosso_server_errors_total") // A09: failed credential checks are alertable. One series per gate, fed from // the process-wide counter the request guards bump. - for (gate in listOf("admin", "ingest", "api_key", "rtdn")) { + for (gate in listOf("admin", "ingest", "api_key")) { io.micrometer.core.instrument.FunctionCounter .builder("glosso_auth_failures_total", authFailureCounts) { counts -> counts[gate]?.get()?.toDouble() ?: 0.0 @@ -952,12 +941,6 @@ fun Application.module( rateLimiter(limit = 120, refillPeriod = 60.seconds) requestKey { call -> call.clientLimitKey() } } - // Verification hits the Play API, so a client cannot turn the endpoint - // into an amplifier against Google. - register(RateLimitName("billing")) { - rateLimiter(limit = 30, refillPeriod = 60.seconds) - requestKey { call -> call.clientLimitKey() } - } // Config and identity are cheap but write a row per first sight; the same // modest per-IP budget keeps a misbehaving client from hammering them. register(RateLimitName("product")) { @@ -1059,16 +1042,13 @@ fun Application.module( accentRoutes(multiAccent, voices) { adminAuthorized(adminToken) } } - // Product configuration: the authority for phase, beta dates, plans, - // flags and the minimum client version. Served like the content - // manifest, with `no-cache` + ETag, so a warm client gets a 304 and - // only pays for the body when the config actually changes. Built per - // request because the phase follows the calendar: past the beta end - // date the body (and so the ETag) changes without a restart. + // Product configuration: flags and the client version policy. Served + // like the content manifest, with `no-cache` + ETag, so a warm client + // gets a 304 and only pays for the body when the config changes. // Contract: docs/PRODUCT-CONFIG-CONTRACT.md. rateLimit(RateLimitName("product")) { get("/config") { - val productConfigJson = productConfig.toJson(Instant.now()) + val productConfigJson = productConfig.toJson() val productConfigEtag = "\"${sha256Hex(productConfigJson).take(16)}\"" call.response.header(HttpHeaders.CacheControl, "no-cache") call.response.header(HttpHeaders.ETag, productConfigEtag) @@ -1082,12 +1062,10 @@ fun Application.module( } } - // The server-computed identity snapshot for one device. The plan - // is decided here, never written by the client, and Beta Founder is - // an independent, permanent status. Contract: section 4 of - // docs/PRODUCT-CONFIG-CONTRACT.md. + // The server-computed identity snapshot for one device. + // Contract: section 4 of docs/PRODUCT-CONFIG-CONTRACT.md. get("/me") { - val service = entitlementService ?: return@get call.respond( + val service = accountService ?: return@get call.respond( HttpStatusCode.ServiceUnavailable, ErrorResponse("Identity is not configured"), ) @@ -1106,11 +1084,11 @@ fun Application.module( // Carry the account of this device to a new one, without any // personal data: `auth/google` signs in with a Google account - // chosen on the device and moves the account here, so the Beta - // Founder status and the lifetime PRO follow the person. + // chosen on the device and moves the account here, so the learner's + // profile follows the person. rateLimit(RateLimitName("account")) { post("/auth/google/nonce") { - val service = entitlementService?.takeIf { it.googleSignInEnabled } + val service = accountService?.takeIf { it.googleSignInEnabled } ?: return@post call.respond( HttpStatusCode.ServiceUnavailable, ErrorResponse("Google sign-in is not configured"), @@ -1125,7 +1103,7 @@ fun Application.module( } post("/auth/google") { - val service = entitlementService?.takeIf { it.googleSignInEnabled } + val service = accountService?.takeIf { it.googleSignInEnabled } ?: return@post call.respond( HttpStatusCode.ServiceUnavailable, ErrorResponse("Google sign-in is not configured"), @@ -1734,7 +1712,7 @@ fun Application.module( } // "Delete my usage data": every event of this install goes. The - // install id is also the user's identity (plan, Beta Founder), + // install id is also the user's identity, // so it is erased from analytics instead of being regenerated. delete("/events") { val store = analytics ?: return@delete call.respond( @@ -2144,83 +2122,6 @@ fun Application.module( } call.respond(ContentPoolResponse(items)) } - - // Server-side purchase verification. The client sends the Play - // purchase token; the server validates it against the Google Play - // Developer API (Android Publisher) with a service account and caches - // the verdict. A client alone cannot be the source of truth for - // entitlement, so if the server cannot verify it answers 503 and - // never grants Pro. Contract: BillingVerifyRequest / - // BillingVerifyResponse. See docs/BILLING-E2E.md. - rateLimit(RateLimitName("billing")) { - post("/billing/verify") { - val request = try { - call.receive() - } catch (e: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ErrorResponse("Invalid verification request body"), - ) - } - when (val outcome = billing.verify(request)) { - is VerifyOutcome.Verdict -> { - persistEntitlement( - call, - request, - outcome.response, - outcome.autoRenew, - entitlementStore, - ) - call.respond(outcome.response) - } - is VerifyOutcome.Invalid -> - call.respond(HttpStatusCode.BadRequest, ErrorResponse(outcome.message)) - is VerifyOutcome.Unavailable -> - call.respond(HttpStatusCode.ServiceUnavailable, ErrorResponse(outcome.message)) - } - } - } - - // Real-time Developer Notifications. Google pushes a PubSubPushEnvelope - // here on renewals, holds, grace, cancellations and refunds; the server - // decodes its base64 DeveloperNotification, invalidates the cached - // verdict and refreshes the state from Play. Protected by a shared - // secret Pub/Sub must present, and optionally pinned to one push - // subscription. Contract: PubSubPushEnvelope. See docs/BILLING-E2E.md. - post("/billing/rtdn") { - if (!billing.rtdnEnabled) { - return@post call.respond( - HttpStatusCode.ServiceUnavailable, - ErrorResponse("RTDN webhook is not configured. See docs/BILLING-E2E.md."), - ) - } - // Header only. A shared secret in the query string is written - // verbatim by every proxy's access log (SEC-08), so the query - // form is gone: Pub/Sub pushes its OIDC token in the - // `Authorization: Bearer` header. Any `?token=` is ignored, not - // silently accepted. - val provided = call.bearerToken() - if (!billing.rtdnAuthorized(provided)) { - recordAuthFailure("rtdn") - return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Unauthorized")) - } - val envelope = try { - call.receive() - } catch (e: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ErrorResponse("Invalid Pub/Sub envelope"), - ) - } - when (val outcome = billing.handleRtdn(envelope)) { - is RtdnOutcome.Accepted -> { - persistRtdnEntitlement(outcome, entitlementStore) - call.respond(HttpStatusCode.OK, mapOf("accepted" to true)) - } - is RtdnOutcome.Rejected -> - call.respond(HttpStatusCode.BadRequest, ErrorResponse(outcome.message)) - } - } } // Human-readable product analytics page (token protected). This one page @@ -2374,15 +2275,6 @@ private fun sha256Hex(text: String): String = .digest(text.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } -/** - * The hash stored and looked up for a Play purchase token. [BillingService] - * validates and caches `purchaseToken.trim()`, so the hash must normalize the - * same way: hashing the raw request would let a token with surrounding spaces - * grant Pro while storing a hash the RTDN lookup can never match, breaking the - * refund fail-closed path. - */ -private fun purchaseTokenHash(token: String): String = sha256Hex(token.trim()) - internal data class ByteRange(val start: Long, val endInclusive: Long) private suspend fun io.ktor.server.application.ApplicationCall.respondAssetFile(file: File) { @@ -2492,84 +2384,6 @@ private fun io.ktor.server.application.ApplicationCall.bearerToken(): String? = request.headers[HttpHeaders.Authorization] ?.removePrefix("Bearer ")?.trim()?.takeIf { it.isNotEmpty() } -/** - * Persists the Play verdict in `glosso.entitlements`, attributed to the device - * that sent the token. Best-effort: a database hiccup must never change the - * verdict the caller already earned, so the write runs in [runCatching] and a - * failure is logged, not surfaced. - */ -private suspend fun persistEntitlement( - call: io.ktor.server.application.ApplicationCall, - request: BillingVerifyRequest, - response: BillingVerifyResponse, - autoRenew: Boolean, - store: EntitlementStore?, -) { - if (store == null) return - val deviceId = (request.deviceId ?: call.request.headers["X-Device-Id"]) - ?.trim() - ?.takeIf { it.isNotEmpty() && it.length <= MAX_ID } - ?: return - runCatching { - withContext(Dispatchers.IO) { - val now = Instant.now() - val user = store.upsertUser(deviceId, now).identity - val existing = store.entitlement(user.id) - val tokenHash = purchaseTokenHash(request.purchaseToken) - // A PERSONALIZED plan is granted outside Play. A non-entitled Play - // verdict for a different purchase must not take it away; only the - // purchase this row already records may revoke it. Without this, - // any expired or foreign token would wipe a manual grant. - if (!response.entitled && - existing?.plan == "PERSONALIZED" && - existing.providerTokenHash != tokenHash - ) { - return@withContext - } - store.upsertEntitlement( - StoredEntitlement( - userId = user.id, - // A paid verdict must not downgrade a PERSONALIZED plan. - plan = if (response.entitled) existing?.providerPlan() ?: "PRO" else "FREE", - status = response.status, - source = "play", - expiresAt = response.expiresAtMillis?.let(Instant::ofEpochMilli), - // A repeated verify records the subscription's start, not - // the moment of the last lookup. - startedAt = existing?.startedAt ?: now, - autoRenew = autoRenew, - provider = "google_play", - providerTokenHash = tokenHash, - ), - ) - } - }.onFailure { logger.warn(it) { "billing entitlement persistence failed" } } -} - -/** - * Persists an RTDN verdict for the user who verified that purchase. RTDN carries - * no device id, so the row is matched by the hash of the purchase token stored at - * verification time. A revoked or voided refund therefore denies entitlement in - * the table at once, not only in the cache. Best-effort, like - * [persistEntitlement]: the webhook is already acknowledged to Pub/Sub. - */ -private suspend fun persistRtdnEntitlement(outcome: RtdnOutcome.Accepted, store: EntitlementStore?) { - val token = outcome.purchaseToken ?: return - val verdict = outcome.entitlement ?: return - if (store == null) return - runCatching { - withContext(Dispatchers.IO) { - store.updateEntitlementsByProviderTokenHash( - providerTokenHash = purchaseTokenHash(token), - entitled = verdict.entitled, - status = verdict.status.name, - expiresAt = verdict.expiresAtMillis?.let(Instant::ofEpochMilli), - autoRenew = verdict.autoRenew, - ) - } - }.onFailure { logger.warn(it) { "rtdn entitlement persistence failed" } } -} - /** * True when the request reached us over TLS. Caddy terminates TLS and forwards * `X-Forwarded-Proto`, so HSTS is only emitted when the client-facing hop was diff --git a/server/src/main/kotlin/com/glosso/server/analytics/AnalyticsStore.kt b/server/src/main/kotlin/com/glosso/server/analytics/AnalyticsStore.kt index 597c9f4..022515b 100644 --- a/server/src/main/kotlin/com/glosso/server/analytics/AnalyticsStore.kt +++ b/server/src/main/kotlin/com/glosso/server/analytics/AnalyticsStore.kt @@ -467,8 +467,6 @@ class AnalyticsStore(private val dataSource: HikariDataSource) : AutoCloseable { "onboarding_completed", "lesson_started", "lesson_completed", - "paywall_viewed", - "purchase_started", ) /** diff --git a/server/src/main/kotlin/com/glosso/server/analytics/ClientEvents.kt b/server/src/main/kotlin/com/glosso/server/analytics/ClientEvents.kt index 54aedd1..f771f2f 100644 --- a/server/src/main/kotlin/com/glosso/server/analytics/ClientEvents.kt +++ b/server/src/main/kotlin/com/glosso/server/analytics/ClientEvents.kt @@ -61,7 +61,7 @@ object ClientEvents { /** The app's screens and sheets, as `screen_view` names them. */ val SCREENS = setOf( "today", "lessons", "you", "library", "lesson", "practice", "stories", "story", "word", - "exercises", "offline", "settings", "about", "privacy", "licenses", "recovery", "paywall", + "exercises", "offline", "settings", "about", "privacy", "licenses", "recovery", "onboarding", "level_picker", "download_sheet", ) private val SCREEN = OneOf(SCREENS) @@ -104,10 +104,6 @@ object ClientEvents { ), "flow_abandoned" to mapOf("flow" to Id, "step" to Id, "reason" to Id), "mic_permission_denied" to mapOf("flow" to Id), - "paywall_viewed" to mapOf("source" to Id), - "pro_cta_tapped" to mapOf("source" to Id), - "purchase_started" to mapOf("product_id" to Id, "plan" to Id), - "purchase_failed" to mapOf("product_id" to Id, "reason" to Id), "screen_view" to mapOf("screen" to SCREEN), "lesson_step" to mapOf("lesson_id" to Id, "step" to LESSON_STEP), "audio_played" to mapOf( diff --git a/server/src/main/kotlin/com/glosso/server/analytics/UmamiMirror.kt b/server/src/main/kotlin/com/glosso/server/analytics/UmamiMirror.kt index 9f72c5a..f1083b8 100644 --- a/server/src/main/kotlin/com/glosso/server/analytics/UmamiMirror.kt +++ b/server/src/main/kotlin/com/glosso/server/analytics/UmamiMirror.kt @@ -109,7 +109,6 @@ class UmamiMirror( "screen_view" -> prop("screen")?.let { "/$it" } "lesson_started" -> prop("lesson_id")?.let { "/lesson/$it" } "story_opened" -> prop("story_id")?.let { "/story/$it" } - "paywall_viewed" -> "/paywall" else -> null } val seconds = (event.occurredAt ?: Instant.now()).epochSecond diff --git a/server/src/main/kotlin/com/glosso/server/api/Dto.kt b/server/src/main/kotlin/com/glosso/server/api/Dto.kt index 73ef3f5..da5d095 100644 --- a/server/src/main/kotlin/com/glosso/server/api/Dto.kt +++ b/server/src/main/kotlin/com/glosso/server/api/Dto.kt @@ -214,35 +214,6 @@ data class ThemeSuggestionResponse( val accepted: Boolean, ) -/** - * Server-side entitlement verification. - * - * A client can always be tampered with, so it can never be the source of truth - * for entitlement: it sends the Play [purchaseToken] here and the server validates - * it against the Google Play Developer API (Android Publisher) with a service - * account. The response carries the verdict and its expiry. A signed offline - * entitlement token is a separate, later step (see docs/BILLING-E2E.md); today - * [BillingVerifyResponse.entitlementToken] is null. - */ -@Serializable -data class BillingVerifyRequest( - val productId: String, - val purchaseToken: String, - val packageName: String, - /** Anonymous, per-install id; never a hardware identifier. */ - val deviceId: String? = null, -) - -@Serializable -data class BillingVerifyResponse( - val entitled: Boolean, - /** NONE, TRIAL, ACTIVE, GRACE, CANCELED or PENDING. */ - val status: String, - /** Reserved for a future signed offline entitlement token; null today. */ - val entitlementToken: String? = null, - val expiresAtMillis: Long? = null, -) - /** * One sound's aggregate in a profile sync. It carries only numbers the on-device * scorer already produced: never audio, never the reference text, never a note. @@ -401,23 +372,3 @@ data class GenerationStatusResponse( val pending: Long, val jobs: Map, ) - -/** - * Pub/Sub push envelope Google sends to the RTDN webhook (`POST /v1/billing/rtdn`). - * [PubSubMessage.data] is the base64-encoded DeveloperNotification JSON that - * carries subscription notifications (renewed, canceled, in grace, on hold, - * revoked, price change). See docs/BILLING-E2E.md. - */ -@Serializable -data class PubSubPushEnvelope( - val message: PubSubMessage, - val subscription: String? = null, -) - -@Serializable -data class PubSubMessage( - val data: String, - val messageId: String? = null, - val publishTime: String? = null, - val attributes: Map = emptyMap(), -) diff --git a/server/src/main/kotlin/com/glosso/server/api/ProductDto.kt b/server/src/main/kotlin/com/glosso/server/api/ProductDto.kt index 8c0288a..d1e66b5 100644 --- a/server/src/main/kotlin/com/glosso/server/api/ProductDto.kt +++ b/server/src/main/kotlin/com/glosso/server/api/ProductDto.kt @@ -1,8 +1,6 @@ package com.glosso.server.api import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject /** * The shape revision of `GET /v1/config`. Bumped only on a breaking change: @@ -11,51 +9,24 @@ import kotlinx.serialization.json.JsonObject const val PRODUCT_CONFIG_SCHEMA = 1 /** - * `GET /v1/config`: the whole product configuration the client consumes. The - * server is the authority (phase, beta dates, plans, flags, minimum version); - * the app never writes any of it. See docs/PRODUCT-CONFIG-CONTRACT.md. + * `GET /v1/config`: flags and the client version policy. The server is the + * authority; the app never writes any of it. See docs/PRODUCT-CONFIG-CONTRACT.md. */ @Serializable data class ProductConfigResponse( val schema: Int = PRODUCT_CONFIG_SCHEMA, - /** `BETA` or `FREE`: the active phase, not a subscription tier. */ - val mode: String, - val beta: BetaDto, - /** - * What each plan includes, keyed by plan. Values are the server's vocabulary - * and configurable (`GLOSSO_PLANS_JSON`); keys are never removed. - */ - val plans: Map, - /** Per-plan limits, or null while undecided. */ - val limits: JsonElement? = null, - /** Prices, or null while undecided: the UI must not sell a null price. */ - val pricing: JsonElement? = null, + /** Legacy: app builds up to 2.6 lock content unless the phase reads as an open beta. */ + val mode: String = "BETA", + /** Legacy: see [mode]. */ + val beta: LegacyBetaDto = LegacyBetaDto(), /** A missing flag is false on the client; new flags need no app release. */ val flags: Map, val client: ClientConfigDto, ) @Serializable -data class BetaDto( - val active: Boolean, - /** ISO-8601 UTC. The single source of the date; the app only formats it. */ - val endDate: String, - val founderEnabled: Boolean, - val founderBenefit: FounderBenefitDto, - /** ISO-8601 UTC. Altas on or before it are Beta Founders; independent of [endDate]. */ - val founderUntil: String, -) - -/** - * The Beta Founder benefit. `value` is null while undecided, and `= null` means - * "do not sell yet". `until` is null while the benefit does not expire. - */ -@Serializable -data class FounderBenefitDto( - val kind: String? = null, - val value: Int? = null, - val until: String? = null, - val label: String? = null, +data class LegacyBetaDto( + val active: Boolean = true, ) @Serializable @@ -65,37 +36,9 @@ data class ClientConfigDto( val forceUpdate: Boolean, ) -/** - * `GET /v1/me`: the server-computed identity snapshot for one device. `plan` and - * `betaFounder` are independent: a Beta Founder gets `PRO` content for free and - * keeps the status even on a higher paid plan. The client caches it for the UI; - * access is still decided server-side. - */ +/** `GET /v1/me`: the server-computed identity snapshot for one device. */ @Serializable data class MeResponse( - val plan: String, - val status: String, - val betaFounder: Boolean, - val betaFounderSince: String? = null, - val betaFounderBenefit: String? = null, - val expiresAt: String? = null, - /** When this founder's benefit ends; null while it does not expire. The status itself never ends. */ - val betaFounderUntil: String? = null, - /** - * Where [plan] comes from: `LIFETIME` (a one-time grant with no renewal or - * expiry, e.g. the Beta Founder PRO), `SUBSCRIPTION` or `FREE`. With - * `LIFETIME` the UI shows permanent access, never renewal or cancellation. - * [status] and [expiresAt] describe the plan only when it is `SUBSCRIPTION`. - */ - val planSource: String = "FREE", - /** The non-revoked lifetime grants of this user. */ - val lifetimeGrants: List = emptyList(), - /** True while the open beta grants this device full access regardless of [plan]. */ - val betaAccess: Boolean = false, - /** The effective entitlements the server grants right now (the plan's, or full access in beta). */ - val entitlements: JsonObject = JsonObject(emptyMap()), - /** The paid subscription behind [plan], or null when none is in force. */ - val subscription: SubscriptionDto? = null, /** * The external accounts (providers, e.g. `google`) this account is linked * to. A device whose account moved elsewhere gets a fresh anonymous user @@ -104,30 +47,6 @@ data class MeResponse( val linkedAccounts: List = emptyList(), ) -/** A one-time plan grant: no expiry, no renewal, no grace period. */ -@Serializable -data class LifetimeGrantDto( - val plan: String, - val source: String, - val grantedAt: String, -) - -/** - * A paid plan in force. `autoRenew=false` with `status=CANCELED` is a cancelled - * subscription still paid through `expiresAt`; `GRACE` is a failed renewal still - * entitled. Restoring on a new device is a new `POST /v1/billing/verify`. - */ -@Serializable -data class SubscriptionDto( - /** TRIAL, ACTIVE, GRACE or CANCELED: the subscription's own state. */ - val status: String, - val provider: String? = null, - val source: String, - val startedAt: String, - val expiresAt: String? = null, - val autoRenew: Boolean, -) - /** * `POST /v1/auth/google/nonce`: a short-lived nonce bound to this device, to be * passed to Credential Manager so Google signs it into the ID token. diff --git a/server/src/main/kotlin/com/glosso/server/billing/BillingService.kt b/server/src/main/kotlin/com/glosso/server/billing/BillingService.kt deleted file mode 100644 index ec2c71a..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/BillingService.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.glosso.server.billing - -import com.glosso.server.api.BillingVerifyRequest -import com.glosso.server.api.BillingVerifyResponse -import com.glosso.server.api.PubSubPushEnvelope -import io.github.oshai.kotlinlogging.KotlinLogging -import java.io.File -import java.security.MessageDigest -import java.util.Base64 - -private val logger = KotlinLogging.logger("GlossoBilling") - -/** - * Server-side billing configuration. Everything here is owned by the deployment - * (environment variables on the VPS), never committed; see docs/BILLING-E2E.md. - */ -data class BillingConfig( - /** Play package the server validates. */ - val packageName: String, - /** The single Pro subscription this server grants. */ - val productId: String, - /** Offer id of the 1-month-free trial, so ACTIVE can be reported as TRIAL. */ - val trialOfferId: String?, - /** How long a verified verdict is trusted before Play is asked again. */ - val cacheTtlMillis: Long, - /** Shared secret Pub/Sub must present on the RTDN webhook; null disables it. */ - val rtdnSecret: String?, - /** Expected Pub/Sub push subscription path, checked when set. */ - val rtdnSubscription: String?, -) - -sealed interface VerifyOutcome { - data class Verdict( - val response: BillingVerifyResponse, - /** Play's `autoRenewing`, kept out of the wire response so it can be persisted. */ - val autoRenew: Boolean = false, - ) : VerifyOutcome - data class Invalid(val message: String) : VerifyOutcome - data class Unavailable(val message: String) : VerifyOutcome -} - -sealed interface RtdnOutcome { - /** - * The notification was authentic and processed. A test notification, or a - * payload with no subscription token, leaves [purchaseToken] and - * [entitlement] null; otherwise the caller persists the new state (a refund - * must deny entitlement even before the client re-verifies). - */ - data class Accepted( - val purchaseToken: String? = null, - val entitlement: Entitlement? = null, - ) : RtdnOutcome - data class Rejected(val message: String) : RtdnOutcome -} - -/** - * Verifies Play purchases against the Play Developer API and caches the verdict. - * - * The client is never trusted: without a successful Play lookup the answer is - * "not entitled" (or a 503 when the server itself cannot verify), never Pro. - * RTDN notifications invalidate the cache and refresh the state immediately, so - * a refund, cancellation or hold is reflected without waiting for the TTL. - */ -class BillingService( - private val client: PlaySubscriptionsClient, - val config: BillingConfig, - private val cache: SubscriptionCache = SubscriptionCache(config.cacheTtlMillis), - private val clock: () -> Long = System::currentTimeMillis, - private val log: (String) -> Unit = {}, -) { - /** True when a service account is configured, so Play can be queried. */ - val verifyEnabled: Boolean get() = client.configured - - /** True when a shared secret is configured, so the RTDN webhook can run. */ - val rtdnEnabled: Boolean get() = !config.rtdnSecret.isNullOrBlank() - - suspend fun verify(request: BillingVerifyRequest): VerifyOutcome { - val token = request.purchaseToken.trim() - if (token.isEmpty()) return VerifyOutcome.Invalid("purchaseToken is required") - if (request.packageName.isBlank() || request.packageName != config.packageName) { - return VerifyOutcome.Invalid("packageName must be ${config.packageName}") - } - if (request.productId.isBlank() || request.productId != config.productId) { - return VerifyOutcome.Invalid("productId must be ${config.productId}") - } - if (!client.configured) { - return VerifyOutcome.Unavailable( - "Server-side purchase verification is not configured. See docs/BILLING-E2E.md.", - ) - } - - cache.get(token)?.let { return VerifyOutcome.Verdict(it.toResponse(), it.autoRenew) } - - val entitlement = try { - val purchase = client.getSubscription(config.packageName, token) - SubscriptionVerifier.verify(purchase, request.productId, config.trialOfferId, clock()) - } catch (e: BillingNotConfiguredException) { - return VerifyOutcome.Unavailable(e.message ?: "Verification is not configured.") - } catch (e: PlayApiException) { - log("Play verification failed: ${e.message}") - return VerifyOutcome.Unavailable( - "Could not verify the purchase with Google Play right now. Try again later.", - ) - } - cache.put(token, entitlement) - return VerifyOutcome.Verdict(entitlement.toResponse(), entitlement.autoRenew) - } - - /** Constant-time check of the shared secret Pub/Sub must present. */ - fun rtdnAuthorized(provided: String?): Boolean { - val secret = config.rtdnSecret ?: return false - if (provided.isNullOrEmpty()) return false - return MessageDigest.isEqual(provided.toByteArray(), secret.toByteArray()) - } - - suspend fun handleRtdn(envelope: PubSubPushEnvelope): RtdnOutcome { - if (!rtdnEnabled) return RtdnOutcome.Rejected("RTDN is not configured") - if (!config.rtdnSubscription.isNullOrBlank() && - envelope.subscription != null && - envelope.subscription != config.rtdnSubscription - ) { - return RtdnOutcome.Rejected("unexpected Pub/Sub subscription") - } - - val notification = RtdnDecoder.decode(envelope.message.data) - ?: return RtdnOutcome.Rejected("message.data is not a valid DeveloperNotification") - if (!notification.packageName.isNullOrBlank() && - notification.packageName != config.packageName - ) { - return RtdnOutcome.Rejected("notification is for package ${notification.packageName}") - } - // A test notification proves the topic is wired; acknowledge it. - if (notification.testNotification != null) return RtdnOutcome.Accepted() - - val subscription = notification.subscriptionNotification - // Same normalization as verify: the cache and the persisted hash must - // key purchases identically even if Play ever pads the token. - val token = (subscription?.purchaseToken - ?: notification.voidedPurchaseNotification?.purchaseToken) - ?.trim() - ?: return RtdnOutcome.Accepted() - - // A revoked or voided purchase loses entitlement at once, without waiting - // for Play: this is the fail-closed path for refunds. - val revoked = subscription?.notificationType == NotificationType.REVOKED || - notification.voidedPurchaseNotification != null - if (revoked) { - val denied = Entitlement(EntitlementStatus.NONE, false, null) - cache.put(token, denied) - log("RTDN revoked/voided purchase; cached not entitled") - return RtdnOutcome.Accepted(purchaseToken = token, entitlement = denied) - } - - cache.invalidate(token) - var refreshed: Entitlement? = null - if (client.configured) { - runCatching { - val purchase = client.getSubscription(config.packageName, token) - val entitlement = SubscriptionVerifier.verify(purchase, config.productId, config.trialOfferId, clock()) - cache.put(token, entitlement) - refreshed = entitlement - log( - "RTDN type=${subscription?.notificationType?.let(::notificationName) ?: "voided"} " + - "refreshed to ${entitlement.status}", - ) - }.onFailure { log("RTDN refresh failed: ${it.message}") } - } - return RtdnOutcome.Accepted(purchaseToken = token, entitlement = refreshed) - } - - private fun Entitlement.toResponse() = BillingVerifyResponse( - entitled = entitled, - status = status.name, - // A signed offline entitlement token needs an asymmetric keypair (private - // on the server, public in the app) and is a separate step: see - // docs/BILLING-E2E.md. The live verdict and its expiry are returned now. - entitlementToken = null, - expiresAtMillis = expiresAtMillis, - ) - - companion object { - private const val DEFAULT_PACKAGE = "me.shirobyte42.glosso" - private const val DEFAULT_PRODUCT_ID = "com.glosso.app.pro" - private const val DEFAULT_CACHE_TTL_SECONDS = 600L - - /** - * Builds the service from the environment. Never throws: a missing or - * broken service account leaves verification disabled, and the endpoint - * answers "cannot verify" rather than granting Pro. - */ - fun fromEnv(env: Map = System.getenv()): BillingService { - val config = BillingConfig( - packageName = env["GLOSSO_PLAY_PACKAGE"]?.takeIf { it.isNotBlank() } ?: DEFAULT_PACKAGE, - productId = env["GLOSSO_PLAY_PRODUCT_ID"]?.takeIf { it.isNotBlank() } ?: DEFAULT_PRODUCT_ID, - trialOfferId = env["GLOSSO_PLAY_TRIAL_OFFER_ID"]?.takeIf { it.isNotBlank() }, - cacheTtlMillis = (env["GLOSSO_BILLING_CACHE_TTL_SECONDS"]?.toLongOrNull() - ?.coerceIn(0, 86_400) ?: DEFAULT_CACHE_TTL_SECONDS) * 1000, - rtdnSecret = env["GLOSSO_RTDN_SECRET"]?.takeIf { it.isNotBlank() }, - rtdnSubscription = env["GLOSSO_RTDN_SUBSCRIPTION"]?.takeIf { it.isNotBlank() }, - ) - - val client = serviceAccountJson(env)?.let { text -> - runCatching { - GooglePlaySubscriptionsClient(ServiceAccountTokenProvider(ServiceAccountKey.fromJson(text))) - }.onFailure { - logger.error(it) { "Invalid Play service account; verification disabled" } - }.getOrNull() - } - if (client == null) { - logger.info { - "Play verification disabled: set GLOSSO_PLAY_SERVICE_ACCOUNT_JSON (or _BASE64)" - } - } - if (config.rtdnSecret == null) { - logger.info { "RTDN webhook disabled: set GLOSSO_RTDN_SECRET" } - } - return BillingService( - client = client ?: UnavailablePlaySubscriptionsClient, - config = config, - log = { logger.info { it } }, - ) - } - - private fun serviceAccountJson(env: Map): String? { - env["GLOSSO_PLAY_SERVICE_ACCOUNT_JSON"]?.takeIf { it.isNotBlank() }?.let { path -> - val file = File(path) - if (file.isFile) return file.readText() - logger.error { - "GLOSSO_PLAY_SERVICE_ACCOUNT_JSON points to a missing file; verification disabled" - } - return null - } - return env["GLOSSO_PLAY_SERVICE_ACCOUNT_JSON_BASE64"]?.takeIf { it.isNotBlank() }?.let { encoded -> - runCatching { String(Base64.getDecoder().decode(encoded)) } - .onFailure { logger.error(it) { "GLOSSO_PLAY_SERVICE_ACCOUNT_JSON_BASE64 is not valid base64" } } - .getOrNull() - } - } - } -} - -private fun notificationName(type: Int): String = when (type) { - NotificationType.RECOVERED -> "RECOVERED" - NotificationType.RENEWED -> "RENEWED" - NotificationType.CANCELED -> "CANCELED" - NotificationType.PURCHASED -> "PURCHASED" - NotificationType.ON_HOLD -> "ON_HOLD" - NotificationType.IN_GRACE_PERIOD -> "IN_GRACE_PERIOD" - NotificationType.RESTARTED -> "RESTARTED" - NotificationType.PRICE_CHANGE_CONFIRMED -> "PRICE_CHANGE_CONFIRMED" - NotificationType.DEFERRED -> "DEFERRED" - NotificationType.PAUSED -> "PAUSED" - NotificationType.PAUSE_SCHEDULE_CHANGED -> "PAUSE_SCHEDULE_CHANGED" - NotificationType.REVOKED -> "REVOKED" - NotificationType.EXPIRED -> "EXPIRED" - else -> "UNKNOWN($type)" -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/DeveloperNotification.kt b/server/src/main/kotlin/com/glosso/server/billing/DeveloperNotification.kt deleted file mode 100644 index 281424c..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/DeveloperNotification.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.glosso.server.billing - -import java.util.Base64 -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -/** - * The DeveloperNotification Google publishes to Pub/Sub for Play RTDN. Only the - * fields needed to identify the purchase and the change are modelled; unknown - * members are ignored so new notification kinds do not break decoding. - */ -@Serializable -data class DeveloperNotification( - val version: String? = null, - val packageName: String? = null, - val eventTimeMillis: String? = null, - val subscriptionNotification: SubscriptionNotification? = null, - val testNotification: TestNotification? = null, - val voidedPurchaseNotification: VoidedPurchaseNotification? = null, -) - -@Serializable -data class SubscriptionNotification( - val version: String? = null, - val notificationType: Int = 0, - val purchaseToken: String? = null, - val subscriptionId: String? = null, -) - -@Serializable -data class TestNotification(val version: String? = null) - -@Serializable -data class VoidedPurchaseNotification( - val purchaseToken: String? = null, - val orderId: String? = null, - val productType: Int = 0, - val refundType: Int = 0, -) - -/** - * The RTDN subscription notification types the server reacts to. The rest are - * acknowledged but need no special handling: invalidating the cache and reading - * the fresh state from Play is correct for all of them. - */ -object NotificationType { - const val RECOVERED = 1 - const val RENEWED = 2 - const val CANCELED = 3 - const val PURCHASED = 4 - const val ON_HOLD = 5 - const val IN_GRACE_PERIOD = 6 - const val RESTARTED = 7 - const val PRICE_CHANGE_CONFIRMED = 8 - const val DEFERRED = 9 - const val PAUSED = 10 - const val PAUSE_SCHEDULE_CHANGED = 11 - const val REVOKED = 12 - const val EXPIRED = 13 -} - -/** Decoding for the base64 payload inside a Pub/Sub push message. */ -object RtdnDecoder { - private val json = Json { ignoreUnknownKeys = true } - - /** - * Decodes the `message.data` field (base64 DeveloperNotification JSON). - * Returns null when it is not valid base64 or not valid JSON, so the caller - * can answer with a clear 400 instead of crashing. - */ - fun decode(base64Data: String): DeveloperNotification? = runCatching { - val bytes = Base64.getDecoder().decode(base64Data) - json.decodeFromString(String(bytes, Charsets.UTF_8)) - }.getOrNull() -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/Entitlement.kt b/server/src/main/kotlin/com/glosso/server/billing/Entitlement.kt deleted file mode 100644 index c620d99..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/Entitlement.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.glosso.server.billing - -/** The subscription states the server can decide, mirroring the client enum. */ -enum class EntitlementStatus { - TRIAL, - ACTIVE, - GRACE, - CANCELED, - PENDING, - NONE, - ; - - /** True when the learner should be treated as Pro right now. */ - val isEntitled: Boolean - get() = this == TRIAL || this == ACTIVE || this == GRACE || this == CANCELED -} - -/** The verdict the server reached for one purchase. */ -data class Entitlement( - val status: EntitlementStatus, - val entitled: Boolean, - val expiresAtMillis: Long?, - /** Play's `autoRenewing` for the line item; false when Play did not say. */ - val autoRenew: Boolean = false, -) - -/** - * Maps a Play subscription snapshot to the server's entitlement model. Kept pure - * (no clock, no network) so every Play state has a test. - * - * Fail-closed rules: - * - an unknown or missing token, or a product not in the purchase, is NONE; - * - ON_HOLD (payment failed and the grace period is over), PAUSED and PENDING - * grant nothing; - * - CANCELED is entitled only while the paid period is still in the future; - * - an unknown Play state grants nothing rather than guessing. - * - * `SUBSCRIPTION_STATE_ACTIVE` is TRIAL only when the configured trial offer is - * the one on the line item; without that config both are ACTIVE. Both grant - * entitlement, so the distinction is cosmetic. - */ -object SubscriptionVerifier { - - fun verify( - purchase: PlaySubscription?, - requestedProductId: String, - trialOfferId: String?, - nowMillis: Long, - ): Entitlement { - if (purchase == null) return Entitlement(EntitlementStatus.NONE, false, null) - - val line = purchase.lineItems.firstOrNull { it.productId == requestedProductId } - ?: return Entitlement(EntitlementStatus.NONE, false, null) - val expiry = line.expiryTimeMillis - - return when (purchase.state) { - "SUBSCRIPTION_STATE_ACTIVE" -> { - val trial = trialOfferId != null && line.offerId == trialOfferId - Entitlement( - status = if (trial) EntitlementStatus.TRIAL else EntitlementStatus.ACTIVE, - entitled = true, - expiresAtMillis = expiry, - autoRenew = line.autoRenewing, - ) - } - "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> - Entitlement(EntitlementStatus.GRACE, true, expiry, line.autoRenewing) - "SUBSCRIPTION_STATE_CANCELED" -> - Entitlement( - EntitlementStatus.CANCELED, - expiry != null && expiry > nowMillis, - expiry, - line.autoRenewing, - ) - "SUBSCRIPTION_STATE_PENDING", - "SUBSCRIPTION_STATE_ON_HOLD", - "SUBSCRIPTION_STATE_PAUSED", - -> Entitlement(EntitlementStatus.PENDING, false, null) - "SUBSCRIPTION_STATE_EXPIRED" -> Entitlement(EntitlementStatus.NONE, false, null) - else -> Entitlement(EntitlementStatus.NONE, false, null) - } - } -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/GoogleAuth.kt b/server/src/main/kotlin/com/glosso/server/billing/GoogleAuth.kt deleted file mode 100644 index 47d4f11..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/GoogleAuth.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.glosso.server.billing - -import java.net.URI -import java.net.URLEncoder -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.security.KeyFactory -import java.security.PrivateKey -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import java.time.Duration -import java.util.Base64 -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -/** - * The fields of a Google Cloud service account key the server actually uses. - * Unknown members are ignored, so the whole downloaded JSON can be passed in. - */ -@Serializable -data class ServiceAccountKey( - @SerialName("client_email") val clientEmail: String = "", - @SerialName("private_key") val privateKey: String = "", - @SerialName("token_uri") val tokenUri: String = "https://oauth2.googleapis.com/token", -) { - companion object { - private val json = Json { ignoreUnknownKeys = true } - - fun fromJson(text: String): ServiceAccountKey = - json.decodeFromString(text).also { key -> - require(key.clientEmail.isNotBlank()) { "service account JSON is missing client_email" } - require(key.privateKey.isNotBlank()) { "service account JSON is missing private_key" } - } - } -} - -@Serializable -private data class OAuthTokenResponse( - @SerialName("access_token") val accessToken: String, - @SerialName("expires_in") val expiresIn: Long = 3600, -) - -/** - * Exchanges a service account key for short-lived OAuth2 access tokens for the - * Android Publisher API. The RS256 JWT-bearer assertion is signed with the JDK - * (no extra dependency) and the access token is cached until shortly before it - * expires, so a burst of verifications costs one token request. - */ -class ServiceAccountTokenProvider( - private val key: ServiceAccountKey, - private val http: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(), - private val clock: () -> Long = System::currentTimeMillis, -) { - private val json = Json { ignoreUnknownKeys = true } - private val privateKey: PrivateKey = parsePrivateKey(key.privateKey) - private val mutex = Mutex() - private val cached = AtomicReference() - - private data class CachedToken(val value: String, val expiresAtMillis: Long) - - suspend fun accessToken(): String { - val now = clock() - cached.get()?.let { if (it.expiresAtMillis - 60_000 > now) return it.value } - return mutex.withLock { - val fresh = clock() - cached.get()?.let { if (it.expiresAtMillis - 60_000 > fresh) return it.value } - val token = withContext(Dispatchers.IO) { exchange(jwtAssertion(fresh)) } - cached.set(CachedToken(token.accessToken, fresh + token.expiresIn * 1000)) - token.accessToken - } - } - - private fun jwtAssertion(nowMillis: Long): String { - val issuedAt = nowMillis / 1000 - val claims = buildJsonObject { - put("iss", key.clientEmail) - put("scope", ANDROID_PUBLISHER_SCOPE) - put("aud", key.tokenUri) - put("iat", issuedAt) - put("exp", issuedAt + 3600) - }.toString() - val signingInput = "${b64(HEADER.toByteArray())}.${b64(claims.toByteArray())}" - val signer = Signature.getInstance("SHA256withRSA").apply { - initSign(privateKey) - update(signingInput.toByteArray()) - } - return "$signingInput.${b64(signer.sign())}" - } - - private fun exchange(assertion: String): OAuthTokenResponse { - val form = "grant_type=${enc("urn:ietf:params:oauth:grant-type:jwt-bearer")}&assertion=${enc(assertion)}" - val request = HttpRequest.newBuilder(URI.create(key.tokenUri)) - .header("Content-Type", "application/x-www-form-urlencoded") - .timeout(Duration.ofSeconds(15)) - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() - val response = http.send(request, HttpResponse.BodyHandlers.ofString()) - if (response.statusCode() !in 200..299) { - throw PlayApiException( - "Google OAuth token request failed (${response.statusCode()})", - response.statusCode(), - ) - } - return json.decodeFromString(response.body()) - } - - private companion object { - const val ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher" - const val HEADER = """{"alg":"RS256","typ":"JWT"}""" - - fun b64(bytes: ByteArray): String = - Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) - - fun enc(value: String): String = - URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20") - - fun parsePrivateKey(pem: String): PrivateKey { - val base64 = pem - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .filterNot { it.isWhitespace() } - val bytes = try { - Base64.getDecoder().decode(base64) - } catch (e: IllegalArgumentException) { - throw BillingNotConfiguredException("service account private_key is not valid base64") - } - return try { - KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(bytes)) - } catch (e: Exception) { - throw BillingNotConfiguredException("service account private_key is not a PKCS#8 RSA key") - } - } - } -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/GooglePlayClient.kt b/server/src/main/kotlin/com/glosso/server/billing/GooglePlayClient.kt deleted file mode 100644 index 0d08892..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/GooglePlayClient.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.glosso.server.billing - -import java.net.URI -import java.net.URLEncoder -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration -import java.time.Instant -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@Serializable -private data class AutoRenewingPlanDto(val autoRenewEnabled: Boolean = false) - -@Serializable -private data class OfferDetailsDto(val basePlanId: String? = null, val offerId: String? = null) - -@Serializable -private data class PlayLineItemDto( - val productId: String = "", - val expiryTime: String? = null, - val autoRenewingPlan: AutoRenewingPlanDto? = null, - val offerDetails: OfferDetailsDto? = null, -) - -@Serializable -private data class CanceledContextDto(val cancelTime: String? = null) - -@Serializable -private data class SubscriptionPurchaseV2Dto( - val subscriptionState: String = "", - val lineItems: List = emptyList(), - val canceledContext: CanceledContextDto? = null, - val acknowledgementState: String? = null, -) - -/** - * Real Play Developer API client: `purchases.subscriptionsv2.get`, authenticated - * with a service account access token. - * - * A 400 or 404 means Play does not know the token (or it does not belong to the - * package), which is a verdict, not an outage: it is returned as null and mapped - * to "not entitled". Every other non-2xx is thrown, so the caller fails closed. - */ -class GooglePlaySubscriptionsClient( - private val tokenProvider: ServiceAccountTokenProvider, - private val http: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(), - private val baseUrl: String = DEFAULT_BASE_URL, -) : PlaySubscriptionsClient { - - override val configured: Boolean = true - - private val json = Json { ignoreUnknownKeys = true } - - override suspend fun getSubscription(packageName: String, purchaseToken: String): PlaySubscription? { - val accessToken = tokenProvider.accessToken() - val url = listOf( - baseUrl.trimEnd('/'), - "androidpublisher/v3/applications", - enc(packageName), - "purchases/subscriptionsv2/tokens", - enc(purchaseToken), - ).joinToString("/") - - val request = HttpRequest.newBuilder(URI.create(url)) - .header("Authorization", "Bearer $accessToken") - .header("Accept", "application/json") - .timeout(Duration.ofSeconds(15)) - .GET() - .build() - - val response = withContext(Dispatchers.IO) { - http.send(request, HttpResponse.BodyHandlers.ofString()) - } - if (response.statusCode() == 400 || response.statusCode() == 404) return null - if (response.statusCode() !in 200..299) { - throw PlayApiException( - "Play subscriptionsv2.get failed (${response.statusCode()})", - response.statusCode(), - ) - } - return json.decodeFromString(response.body()).toPlaySubscription() - } - - private fun SubscriptionPurchaseV2Dto.toPlaySubscription() = PlaySubscription( - state = subscriptionState, - lineItems = lineItems.map { line -> - PlayLineItem( - productId = line.productId, - expiryTimeMillis = line.expiryTime.toEpochMillis(), - autoRenewing = line.autoRenewingPlan?.autoRenewEnabled ?: false, - offerId = line.offerDetails?.offerId, - ) - }, - cancelTimeMillis = canceledContext?.cancelTime.toEpochMillis(), - acknowledgeState = acknowledgementState, - ) - - private fun String?.toEpochMillis(): Long? = - this?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } - - private fun enc(value: String): String = - URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20") - - companion object { - const val DEFAULT_BASE_URL = "https://androidpublisher.googleapis.com" - } -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/PlaySubscriptions.kt b/server/src/main/kotlin/com/glosso/server/billing/PlaySubscriptions.kt deleted file mode 100644 index 5809815..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/PlaySubscriptions.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.glosso.server.billing - -/** - * One subscription line item as returned by the Google Play Developer API - * (`purchases.subscriptionsv2.get`). A multi-product subscription has several, - * but the Glosso subscription has exactly one. - */ -data class PlayLineItem( - val productId: String, - val expiryTimeMillis: Long?, - val autoRenewing: Boolean, - val offerId: String?, -) - -/** - * The slice of a Play subscription purchase that entitlement needs, kept as plain - * values (not the JSON DTO) so the mapping is testable without the network. - */ -data class PlaySubscription( - val state: String, - val lineItems: List, - val cancelTimeMillis: Long?, - val acknowledgeState: String?, -) - -/** - * Raised when Play cannot answer (network, credentials, 5xx), so the caller must - * fail closed instead of assuming Pro. - */ -class PlayApiException(message: String, val statusCode: Int? = null) : Exception(message) - -/** Raised when the server has no service account, so verification cannot run. */ -class BillingNotConfiguredException(message: String) : Exception(message) - -/** - * Reads a subscription purchase from the Play Developer API. Returns null when - * Play reports the token as unknown (a verdict: not entitled); throws - * [PlayApiException] when the API is unreachable or rejects our credentials. - */ -interface PlaySubscriptionsClient { - suspend fun getSubscription(packageName: String, purchaseToken: String): PlaySubscription? - - /** False when there is no service account, so verification cannot run. */ - val configured: Boolean -} - -/** Used when billing is not configured: every verification says "cannot verify". */ -object UnavailablePlaySubscriptionsClient : PlaySubscriptionsClient { - override val configured: Boolean = false - - override suspend fun getSubscription(packageName: String, purchaseToken: String): PlaySubscription = - throw BillingNotConfiguredException( - "Play verification is not configured (no service account). See docs/BILLING-E2E.md.", - ) -} diff --git a/server/src/main/kotlin/com/glosso/server/billing/SubscriptionCache.kt b/server/src/main/kotlin/com/glosso/server/billing/SubscriptionCache.kt deleted file mode 100644 index 54ceebb..0000000 --- a/server/src/main/kotlin/com/glosso/server/billing/SubscriptionCache.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.glosso.server.billing - -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory TTL cache of verified purchases, keyed by a hash of the purchase - * token so raw tokens are never retained. Negative verdicts are cached too, so a - * forged or expired token cannot be used to hammer the Play API. RTDN webhooks - * invalidate an entry the moment Play reports a lifecycle change. - * - * A positive verdict is never cached past the subscription expiry, so an - * entitlement cannot outlive the period it was paid for. - */ -class SubscriptionCache( - private val ttlMillis: Long, - private val clock: () -> Long = System::currentTimeMillis, -) { - private data class Entry(val entitlement: Entitlement, val freshUntilMillis: Long) - - private val entries = ConcurrentHashMap() - - fun get(purchaseToken: String): Entitlement? { - val key = key(purchaseToken) - val entry = entries[key] ?: return null - if (clock() >= entry.freshUntilMillis) { - entries.remove(key, entry) - return null - } - return entry.entitlement - } - - fun put(purchaseToken: String, entitlement: Entitlement) { - val now = clock() - val ttl = if (entitlement.entitled && entitlement.expiresAtMillis != null) { - minOf(ttlMillis, entitlement.expiresAtMillis - now) - } else { - ttlMillis - } - if (ttl <= 0) return - entries[key(purchaseToken)] = Entry(entitlement, now + ttl) - } - - fun invalidate(purchaseToken: String) { - entries.remove(key(purchaseToken)) - } - - fun clear() = entries.clear() - - private fun key(token: String): String = - MessageDigest.getInstance("SHA-256").digest(token.toByteArray()) - .joinToString("") { "%02x".format(it) } -} diff --git a/server/src/main/kotlin/com/glosso/server/product/AccountService.kt b/server/src/main/kotlin/com/glosso/server/product/AccountService.kt new file mode 100644 index 0000000..c41274f --- /dev/null +++ b/server/src/main/kotlin/com/glosso/server/product/AccountService.kt @@ -0,0 +1,78 @@ +package com.glosso.server.product + +import com.glosso.server.api.MeResponse +import java.time.Instant + +/** The outcome of [AccountService.signInWithGoogle]. */ +sealed interface GoogleSignInResult { + /** Signed in; [moved] is true when the account came from another device. */ + data class SignedIn(val me: MeState, val moved: Boolean) : GoogleSignInResult + + /** The token is not a valid Google ID token for this server and device. */ + data object InvalidToken : GoogleSignInResult + + /** This device's account is already linked to a different Google account. */ + data object Conflict : GoogleSignInResult +} + +/** The server-computed identity snapshot served by `GET /v1/me`. */ +data class MeState( + val linkedAccounts: List = emptyList(), +) + +fun MeState.toResponse(): MeResponse = MeResponse(linkedAccounts = linkedAccounts) + +/** + * Device identity and Sign in with Google. Everything in the app is free, so + * an account only exists to carry a learner's profile to a new phone. + */ +class AccountService( + private val store: AccountStore, + private val clock: () -> Instant = Instant::now, + /** Keys sign-in nonces; null disables Sign in with Google. */ + private val accountSecret: AccountSecret? = null, + /** Verifies Google ID tokens; null disables Sign in with Google. */ + private val googleVerifier: GoogleIdTokenVerifier? = null, +) { + /** Sign in with Google needs a client id to verify against and the nonce secret. */ + val googleSignInEnabled: Boolean get() = accountSecret != null && googleVerifier != null + + fun identify(deviceId: String): UserIdentity = store.upsertUser(deviceId, clock()).identity + + /** + * A fresh nonce for Sign in with Google on [deviceId]. The app hands it to + * Credential Manager, Google signs it into the ID token, and + * [signInWithGoogle] only accepts a token carrying a nonce issued for the + * same device, so a token cannot be replayed from another device. + */ + fun googleSignInNonce(deviceId: String): String { + val secret = checkNotNull(accountSecret) { "sign-in is not configured" } + return secret.signInNonce(deviceId, clock()) + } + + /** + * Signs [deviceId] in with a Google ID token chosen in the system account + * picker. The first sign-in links the Google account to this device's + * account; a sign-in on another device moves the account there. Only the + * token's opaque `sub` is kept. + */ + fun signInWithGoogle(deviceId: String, idToken: String): GoogleSignInResult { + val secret = checkNotNull(accountSecret) { "sign-in is not configured" } + val verifier = checkNotNull(googleVerifier) { "sign-in is not configured" } + val google = verifier.verify(idToken) ?: return GoogleSignInResult.InvalidToken + val nonce = google.nonce ?: return GoogleSignInResult.InvalidToken + if (!secret.isValidSignInNonce(nonce, deviceId, clock())) return GoogleSignInResult.InvalidToken + // The device row must exist before it can be linked or merged into an account. + identify(deviceId) + return when (val result = store.signInWithExternalAccount(GOOGLE_PROVIDER, google.subject, deviceId, clock())) { + ExternalSignIn.Conflict -> GoogleSignInResult.Conflict + is ExternalSignIn.SignedIn -> GoogleSignInResult.SignedIn(me(deviceId), result.moved) + } + } + + fun me(deviceId: String): MeState = MeState(linkedAccounts = store.linkedProviders(identify(deviceId).id)) + + companion object { + const val GOOGLE_PROVIDER = "google" + } +} diff --git a/server/src/main/kotlin/com/glosso/server/product/AccountStore.kt b/server/src/main/kotlin/com/glosso/server/product/AccountStore.kt new file mode 100644 index 0000000..2dfa681 --- /dev/null +++ b/server/src/main/kotlin/com/glosso/server/product/AccountStore.kt @@ -0,0 +1,73 @@ +package com.glosso.server.product + +import java.time.Instant +import java.util.UUID + +/** A `glosso.users` row: the anonymous device identity. */ +data class UserIdentity( + val id: UUID, + val deviceId: String, + val createdAt: Instant, +) + +/** The result of an identity upsert, with whether the user was just created. */ +data class UserUpsert( + val identity: UserIdentity, + val created: Boolean, +) + +/** The outcome of [AccountStore.signInWithExternalAccount]. */ +sealed interface ExternalSignIn { + /** Signed in; [moved] is true when the account came from another device. */ + data class SignedIn(val identity: UserIdentity, val moved: Boolean) : ExternalSignIn + + /** The device's account is already linked to a different identity of the provider. */ + data object Conflict : ExternalSignIn +} + +/** + * A user row an account flow needed vanished or moved under it: another + * request merged it or moved its device between the read and the lock. Nothing + * was written; the client retries and sees the new state (409 on the API). + */ +class AccountChangedConcurrently(message: String) : RuntimeException(message) + +/** + * Persistence for device identity and linked accounts. Services depend on this, + * not on JDBC, so the decision logic is testable without a database. + * + * Moving an account to a new device (an external sign-in): + * the account's `glosso.users` row adopts the new `device_id`. Whatever the new + * device had already built on its own placeholder row is merged into the + * account, never dropped: learner and phoneme profiles keep the most recently + * updated row per key, open needs keep the most recent side per language, and + * an external link is carried over when the account lacks one. Only then is + * the emptied placeholder removed. The old device loses the account; on its + * next call it simply starts a new anonymous one. + */ +interface AccountStore : AutoCloseable { + + /** Resolves a device id to its user, creating the user on first sight. */ + fun upsertUser(deviceId: String, now: Instant): UserUpsert + + /** + * Signs [deviceId] in with the external identity ([provider], [subject]), + * for example the `sub` of a verified Google ID token: + * - not linked yet: it is linked to the device's current user, which + * becomes a stable account; + * - linked to the device's user: nothing changes; + * - linked to another user: that account moves to this device, merging + * what this device had (see the account move above). + * The device user must already exist ([upsertUser]). Returns + * [ExternalSignIn.Conflict] when the device's user is already linked to a + * different identity of the same provider: one person, one account. + * Throws [AccountChangedConcurrently] when the device's user or the link + * changed under a concurrent flow. + */ + fun signInWithExternalAccount(provider: String, subject: String, deviceId: String, now: Instant): ExternalSignIn + + /** The providers [userId] is linked to, sorted; never the subjects. */ + fun linkedProviders(userId: UUID): List + + override fun close() {} +} diff --git a/server/src/main/kotlin/com/glosso/server/product/EntitlementService.kt b/server/src/main/kotlin/com/glosso/server/product/EntitlementService.kt deleted file mode 100644 index 53a3a67..0000000 --- a/server/src/main/kotlin/com/glosso/server/product/EntitlementService.kt +++ /dev/null @@ -1,228 +0,0 @@ -package com.glosso.server.product - -import com.glosso.server.api.LifetimeGrantDto -import com.glosso.server.api.MeResponse -import com.glosso.server.api.SubscriptionDto -import com.glosso.server.billing.EntitlementStatus -import java.time.Instant -import java.time.format.DateTimeFormatter -import kotlinx.serialization.json.JsonObject - -/** Where the effective plan comes from. */ -enum class PlanSource { LIFETIME, SUBSCRIPTION, FREE } - -/** The outcome of [EntitlementService.signInWithGoogle]. */ -sealed interface GoogleSignInResult { - /** Signed in; [moved] is true when the account came from another device. */ - data class SignedIn(val me: MeState, val moved: Boolean) : GoogleSignInResult - - /** The token is not a valid Google ID token for this server and device. */ - data object InvalidToken : GoogleSignInResult - - /** This device's account is already linked to a different Google account. */ - data object Conflict : GoogleSignInResult -} - -/** The server-computed identity snapshot served by `GET /v1/me`. */ -data class MeState( - val plan: String, - val status: String, - val betaFounder: Boolean, - val betaFounderSince: Instant?, - val betaFounderBenefit: String?, - val expiresAt: Instant?, - val betaFounderUntil: Instant? = null, - val planSource: PlanSource = PlanSource.FREE, - val lifetimeGrants: List = emptyList(), - val betaAccess: Boolean = false, - val entitlements: JsonObject = JsonObject(emptyMap()), - val subscription: StoredEntitlement? = null, - val linkedAccounts: List = emptyList(), -) - -private val ISO: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT - -fun MeState.toResponse(): MeResponse = MeResponse( - plan = plan, - status = status, - betaFounder = betaFounder, - betaFounderSince = betaFounderSince?.let(ISO::format), - betaFounderBenefit = betaFounderBenefit, - expiresAt = expiresAt?.let(ISO::format), - betaFounderUntil = betaFounderUntil?.let(ISO::format), - planSource = planSource.name, - lifetimeGrants = lifetimeGrants.map { LifetimeGrantDto(it.plan, it.source, ISO.format(it.grantedAt)) }, - betaAccess = betaAccess, - entitlements = entitlements, - subscription = subscription?.let { - SubscriptionDto( - status = it.status, - provider = it.provider, - source = it.source, - startedAt = ISO.format(it.startedAt), - expiresAt = it.expiresAt?.let(ISO::format), - autoRenew = it.autoRenew, - ) - }, - linkedAccounts = linkedAccounts, -) - -/** - * Decides identity, Beta Founder status and the effective plan. The client never - * writes any of it; it only caches what the server answers. - * - * Beta Founder is permanent: marked once, when the founder flag is enabled and - * the alta happened on or before the founder window end - * ([BetaConfig.founderUntil]). The window is independent of the beta end date - * and of the current mode. The status is never cleared or recomputed when the - * plan changes, the beta ends or the window closes. - * - * With the `PRO_LIFETIME` benefit a founder also gets a lifetime grant of PRO: - * a one-time entitlement with no expiry, renewal or grace period, persisted in - * `glosso.lifetime_grants` so a later config change cannot take it away. It - * never grants `PERSONALIZED`. Subscriptions are a separate, orthogonal - * mechanism: the effective plan is the highest of the in-force subscription and - * the lifetime grants. - * - * While the free beta is open every device, founder or not, gets full access - * ([MeState.betaAccess] and the top plan's entitlements); [MeState.plan] still - * says what the user keeps once the beta ends. - */ -class EntitlementService( - private val store: EntitlementStore, - private val config: ProductConfig, - private val clock: () -> Instant = Instant::now, - /** Keys sign-in nonces; null disables Sign in with Google. */ - private val accountSecret: AccountSecret? = null, - /** Verifies Google ID tokens; null disables Sign in with Google. */ - private val googleVerifier: GoogleIdTokenVerifier? = null, -) { - /** Sign in with Google needs a client id to verify against and the nonce secret. */ - val googleSignInEnabled: Boolean get() = accountSecret != null && googleVerifier != null - - fun identify(deviceId: String): UserIdentity { - val now = clock() - val upsert = store.upsertUser(deviceId, now) - var identity = upsert.identity - if (!identity.betaFounder && config.isFounderAlta(identity.createdAt)) { - store.markBetaFounder( - userId = identity.id, - since = identity.createdAt, - until = config.beta.founderBenefit.until, - benefit = config.beta.founderBenefit.kind, - ) - identity = identity.copy( - betaFounder = true, - betaFounderSince = identity.createdAt, - betaFounderUntil = config.beta.founderBenefit.until, - betaFounderBenefit = config.beta.founderBenefit.kind, - ) - if (config.beta.founderBenefit.grantsProLifetime) { - store.grantLifetime(identity.id, "PRO", BETA_FOUNDER_SOURCE, identity.createdAt) - } - } - return identity - } - - /** - * A fresh nonce for Sign in with Google on [deviceId]. The app hands it to - * Credential Manager, Google signs it into the ID token, and - * [signInWithGoogle] only accepts a token carrying a nonce issued for the - * same device, so a token cannot be replayed from another device. - */ - fun googleSignInNonce(deviceId: String): String { - val secret = checkNotNull(accountSecret) { "sign-in is not configured" } - return secret.signInNonce(deviceId, clock()) - } - - /** - * Signs [deviceId] in with a Google ID token chosen in the system account - * picker. The first sign-in links the Google account to this device's - * account; a sign-in on another device moves the account there, so the - * Beta Founder status and the lifetime PRO follow the person, not the - * phone. Only the token's opaque `sub` is kept. - */ - fun signInWithGoogle(deviceId: String, idToken: String): GoogleSignInResult { - val secret = checkNotNull(accountSecret) { "sign-in is not configured" } - val verifier = checkNotNull(googleVerifier) { "sign-in is not configured" } - val google = verifier.verify(idToken) ?: return GoogleSignInResult.InvalidToken - val nonce = google.nonce ?: return GoogleSignInResult.InvalidToken - if (!secret.isValidSignInNonce(nonce, deviceId, clock())) return GoogleSignInResult.InvalidToken - // The device row must exist (and be marked founder if due) before it - // can be linked or merged into an account. - identify(deviceId) - return when (val result = store.signInWithExternalAccount(GOOGLE_PROVIDER, google.subject, deviceId, clock())) { - ExternalSignIn.Conflict -> GoogleSignInResult.Conflict - is ExternalSignIn.SignedIn -> GoogleSignInResult.SignedIn(me(deviceId), result.moved) - } - } - - fun me(deviceId: String): MeState { - val identity = identify(deviceId) - val now = clock() - val grants = founderGrants(identity) - val stored = store.entitlement(identity.id) - val entitled = stored != null && stored.isInForce(now) - val subscriptionPlan = stored?.plan?.takeIf { entitled && it in PAID_PLANS } - val lifetimePlan = grants.map { it.plan }.filter { it in PAID_PLANS }.maxByOrNull(::planRank) - // A lifetime plan wins a tie: permanent access is what the user has, - // and a PRO subscription on top of a founder PRO adds nothing to show. - val planSource = when { - subscriptionPlan != null && planRank(subscriptionPlan) > planRank(lifetimePlan) -> PlanSource.SUBSCRIPTION - lifetimePlan != null -> PlanSource.LIFETIME - else -> PlanSource.FREE - } - val plan = when (planSource) { - PlanSource.SUBSCRIPTION -> subscriptionPlan!! - PlanSource.LIFETIME -> lifetimePlan!! - PlanSource.FREE -> "FREE" - } - // Plan and status must agree: a paid status only ever comes with the - // subscription that grants the plan. A lifetime plan has no renewal, - // so it reports NONE with no expiry; the subscription, if any, is still - // described in [MeState.subscription]. - val status = when { - planSource == PlanSource.SUBSCRIPTION -> stored!!.status - planSource == PlanSource.FREE && stored?.status == EntitlementStatus.PENDING.name -> - EntitlementStatus.PENDING.name - else -> EntitlementStatus.NONE.name - } - return MeState( - plan = plan, - status = status, - betaFounder = identity.betaFounder, - betaFounderSince = identity.betaFounderSince, - betaFounderBenefit = identity.betaFounderBenefit, - expiresAt = if (planSource == PlanSource.SUBSCRIPTION) stored!!.expiresAt else null, - betaFounderUntil = identity.betaFounderUntil, - planSource = planSource, - lifetimeGrants = grants, - betaAccess = config.betaActiveAt(now), - entitlements = config.entitlementsFor(plan, now), - subscription = stored?.takeIf { subscriptionPlan != null }, - linkedAccounts = store.linkedProviders(identity.id), - ) - } - - /** - * The user's lifetime grants, making sure a founder under the - * `PRO_LIFETIME` benefit has one. Idempotent, so it also heals founders - * marked before the grant existed or while another benefit was configured. - */ - private fun founderGrants(identity: UserIdentity): List { - val grants = store.lifetimeGrants(identity.id) - val owed = identity.betaFounder && - config.beta.founderBenefit.grantsProLifetime && - grants.none { it.source == BETA_FOUNDER_SOURCE } - if (!owed) return grants - val grantedAt = identity.betaFounderSince ?: identity.createdAt - store.grantLifetime(identity.id, "PRO", BETA_FOUNDER_SOURCE, grantedAt) - return store.lifetimeGrants(identity.id) - } - - companion object { - const val BETA_FOUNDER_SOURCE = "beta_founder" - - const val GOOGLE_PROVIDER = "google" - } -} diff --git a/server/src/main/kotlin/com/glosso/server/product/EntitlementStore.kt b/server/src/main/kotlin/com/glosso/server/product/EntitlementStore.kt deleted file mode 100644 index e76e704..0000000 --- a/server/src/main/kotlin/com/glosso/server/product/EntitlementStore.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.glosso.server.product - -import com.glosso.server.billing.EntitlementStatus -import java.time.Instant -import java.util.UUID - -/** - * A `glosso.users` row: the anonymous device identity plus the permanent Beta - * Founder status. Once set, the founder status is never cleared or recomputed. - */ -data class UserIdentity( - val id: UUID, - val deviceId: String, - val createdAt: Instant, - val betaFounder: Boolean, - val betaFounderSince: Instant?, - val betaFounderUntil: Instant?, - val betaFounderBenefit: String?, -) - -/** The result of an identity upsert, with whether the user was just created. */ -data class UserUpsert( - val identity: UserIdentity, - val created: Boolean, -) - -/** - * One effective entitlement, as persisted in `glosso.entitlements`. `plan` and - * `status` are the raw stored values; the effective plan is resolved by - * [EntitlementService]. - */ -data class StoredEntitlement( - val userId: UUID, - val plan: String, - val status: String, - val source: String, - val expiresAt: Instant?, - val startedAt: Instant, - val autoRenew: Boolean, - val provider: String?, - val providerTokenHash: String?, -) - -/** - * A one-time entitlement from `glosso.lifetime_grants`: a plan granted for life, - * with no expiry, renewal or grace period. The Beta Founder PRO is one - * (`source = beta_founder`); a future one-time purchase would be another - * (`source = purchase`). Only non-revoked grants are ever loaded. - */ -data class LifetimeGrant( - val userId: UUID, - val plan: String, - val source: String, - val provider: String?, - val grantedAt: Instant, -) - -/** The outcome of [EntitlementStore.signInWithExternalAccount]. */ -sealed interface ExternalSignIn { - /** Signed in; [moved] is true when the account came from another device. */ - data class SignedIn(val identity: UserIdentity, val moved: Boolean) : ExternalSignIn - - /** The device's account is already linked to a different identity of the provider. */ - data object Conflict : ExternalSignIn -} - -/** - * A user row an account flow needed vanished or moved under it: another - * request merged it or moved its device between the read and the lock. Nothing - * was written; the client retries and sees the new state (409 on the API). - */ -class AccountChangedConcurrently(message: String) : RuntimeException(message) - -/** - * Play sells one paid tier, PRO; a row already on a higher plan (PERSONALIZED) - * is never downgraded when Play reports it as entitled. - */ -fun StoredEntitlement.providerPlan(): String = - if (plan == "PRO" || plan == "PERSONALIZED") plan else "PRO" - -/** The plans that are paid for, as opposed to FREE. */ -val PAID_PLANS: Set = setOf("PRO", "PERSONALIZED") - -/** The order of plans, for picking the highest one. */ -fun planRank(plan: String?): Int = when (plan) { - "PERSONALIZED" -> 2 - "PRO" -> 1 - else -> 0 -} - -/** - * A stored verdict only grants a paid plan while its status entitles and, when - * an expiry is known, that expiry is still in the future. This demotes both an - * ACTIVE row whose period lapsed and a CANCELED one past its paid-through - * date, so an entitlement that Play has not refreshed yet cannot over-grant. - */ -fun StoredEntitlement.isInForce(now: Instant): Boolean { - val status = runCatching { EntitlementStatus.valueOf(status) }.getOrNull() ?: return false - if (!status.isEntitled) return false - return expiresAt == null || expiresAt.isAfter(now) -} - -/** - * Orders two entitlement rows when an account move merges two users and only - * one row can stay: a paid plan in force beats anything that is not, then the - * higher plan, then the one that runs longer (no expiry is longest). - */ -fun entitlementMergeOrder(now: Instant): Comparator = compareBy( - { it.isInForce(now) && it.plan in PAID_PLANS }, - { planRank(it.plan) }, - { it.expiresAt ?: Instant.MAX }, -) - -/** - * Persistence for identity and entitlements. Services depend on this, not on - * JDBC, so the decision logic is testable without a database. - * - * Moving an account to a new device (an external sign-in): - * the account's `glosso.users` row adopts the new `device_id`, so the new device - * inherits the Beta Founder status and the lifetime grants. Whatever the new - * device had already built on its own placeholder row is merged into the - * account, never dropped: learner and phoneme profiles keep the most recently - * updated row per key, open needs keep the most recent side per language, - * lifetime grants and an external link are carried over when the account lacks - * them, the Beta Founder status is the union of both, and of the two - * entitlement rows (there is one per user) the highest by - * [entitlementMergeOrder] is kept; the other one is dropped with the - * placeholder. Only then is the emptied placeholder - * removed. The old device loses the account; on its next call it simply starts - * a new anonymous one. - */ -interface EntitlementStore : AutoCloseable { - - /** Resolves a device id to its user, creating the user on first sight. */ - fun upsertUser(deviceId: String, now: Instant): UserUpsert - - /** Marks a user as a founder once; a no-op when already marked. */ - fun markBetaFounder(userId: UUID, since: Instant, until: Instant?, benefit: String?) - - fun entitlement(userId: UUID): StoredEntitlement? - - /** - * Applies an RTDN verdict to every entitlement verified with [providerTokenHash]. - * A purchase token is not unique: the same purchase can have been verified - * from several devices, so a refund or renewal has to reach all of them, not - * just the first row a lookup returns. The `provider_token_hash` is preserved - * so later notifications for the same token still find the rows. A - * `PERSONALIZED` row is a grant made outside Play and is left untouched: a - * Play notification cannot revoke it. [autoRenew] carries a cancellation - * (Play stops auto-renewing while the paid period still runs). - */ - fun updateEntitlementsByProviderTokenHash( - providerTokenHash: String, - entitled: Boolean, - status: String, - expiresAt: Instant?, - autoRenew: Boolean, - ) - - fun upsertEntitlement(entitlement: StoredEntitlement) - - /** The user's non-revoked lifetime grants. */ - fun lifetimeGrants(userId: UUID): List - - /** - * Records a lifetime grant once per (user, plan, source); a repeat is a - * no-op, and a grant revoked for fraud is never silently restored. - */ - fun grantLifetime( - userId: UUID, - plan: String, - source: String, - grantedAt: Instant, - provider: String? = null, - providerRefHash: String? = null, - ) - - /** - * Signs [deviceId] in with the external identity ([provider], [subject]), - * for example the `sub` of a verified Google ID token: - * - not linked yet: it is linked to the device's current user, which - * becomes a stable account; - * - linked to the device's user: nothing changes; - * - linked to another user: that account moves to this device, merging - * what this device had (see the account move above). - * The device user must already exist ([upsertUser]). Returns - * [ExternalSignIn.Conflict] when the device's user is already linked to a - * different identity of the same provider: one person, one account. - * Throws [AccountChangedConcurrently] when the device's user or the link - * changed under a concurrent flow. - */ - fun signInWithExternalAccount(provider: String, subject: String, deviceId: String, now: Instant): ExternalSignIn - - /** The providers [userId] is linked to, sorted; never the subjects. */ - fun linkedProviders(userId: UUID): List - - override fun close() {} -} diff --git a/server/src/main/kotlin/com/glosso/server/product/JdbcEntitlementStore.kt b/server/src/main/kotlin/com/glosso/server/product/JdbcAccountStore.kt similarity index 62% rename from server/src/main/kotlin/com/glosso/server/product/JdbcEntitlementStore.kt rename to server/src/main/kotlin/com/glosso/server/product/JdbcAccountStore.kt index a70cf97..1a20d90 100644 --- a/server/src/main/kotlin/com/glosso/server/product/JdbcEntitlementStore.kt +++ b/server/src/main/kotlin/com/glosso/server/product/JdbcAccountStore.kt @@ -9,10 +9,10 @@ import java.util.UUID import javax.sql.DataSource /** - * PostgreSQL implementation of identity and entitlement persistence. Raw JDBC, - * like the rest of the control plane: no ORM, one shared pool. + * PostgreSQL implementation of identity and linked-account persistence. Raw + * JDBC, like the rest of the control plane: no ORM, one shared pool. */ -class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStore { +class JdbcAccountStore(private val dataSource: DataSource) : AccountStore { override fun upsertUser(deviceId: String, now: Instant): UserUpsert { dataSource.connection.use { connection -> @@ -23,8 +23,7 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor insert into glosso.users (device_id, created_at, updated_at) values (?, ?, ?) on conflict (device_id) do nothing - returning id, device_id, created_at, beta_founder, beta_founder_since, - beta_founder_until, beta_founder_benefit + returning id, device_id, created_at """.trimIndent(), ).use { statement -> statement.setString(1, deviceId) @@ -37,8 +36,7 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor // bump updated_at: upsertUser also serves the hot GET /v1/me. connection.prepareStatement( """ - select id, device_id, created_at, beta_founder, beta_founder_since, - beta_founder_until, beta_founder_benefit + select id, device_id, created_at from glosso.users where device_id = ? """.trimIndent(), ).use { statement -> @@ -61,167 +59,6 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor } } - override fun markBetaFounder(userId: UUID, since: Instant, until: Instant?, benefit: String?) { - dataSource.connection.use { connection -> - connection.prepareStatement( - """ - update glosso.users - set beta_founder = true, - beta_founder_since = coalesce(beta_founder_since, ?), - beta_founder_until = coalesce(beta_founder_until, ?), - beta_founder_benefit = coalesce(beta_founder_benefit, ?), - updated_at = now() - where id = ? and beta_founder = false - """.trimIndent(), - ).use { statement -> - statement.setTimestamp(1, Timestamp.from(since)) - statement.setTimestamp(2, until?.let(Timestamp::from)) - statement.setString(3, benefit) - statement.setObject(4, userId) - statement.executeUpdate() - } - } - } - - override fun entitlement(userId: UUID): StoredEntitlement? = - dataSource.connection.use { connection -> entitlement(connection, userId) } - - private fun entitlement(connection: Connection, userId: UUID): StoredEntitlement? = - connection.prepareStatement( - """ - select user_id, plan, status, source, expires_at, started_at, auto_renew, - provider, provider_token_hash - from glosso.entitlements where user_id = ? - """.trimIndent(), - ).use { statement -> - statement.setObject(1, userId) - statement.executeQuery().use { rs -> if (rs.next()) rs.toEntitlement() else null } - } - - override fun updateEntitlementsByProviderTokenHash( - providerTokenHash: String, - entitled: Boolean, - status: String, - expiresAt: Instant?, - autoRenew: Boolean, - ) { - dataSource.connection.use { connection -> - // One statement for every Play row with the token: a refund cannot - // leave another device on the same purchase still PRO. PERSONALIZED - // is a grant made outside Play, so a Play notification never lowers - // it: the WHERE leaves those rows untouched, hash included. - connection.prepareStatement( - """ - update glosso.entitlements - set plan = case when ? then 'PRO' else 'FREE' end, - status = ?, - expires_at = ?, - auto_renew = ?, - provider = coalesce(provider, 'google_play'), - updated_at = now() - where provider_token_hash = ? and plan <> 'PERSONALIZED' - """.trimIndent(), - ).use { statement -> - statement.setBoolean(1, entitled) - statement.setString(2, status) - statement.setTimestamp(3, expiresAt?.let(Timestamp::from)) - statement.setBoolean(4, autoRenew) - statement.setString(5, providerTokenHash) - statement.executeUpdate() - } - } - } - - override fun upsertEntitlement(entitlement: StoredEntitlement) { - dataSource.connection.use { connection -> - connection.prepareStatement( - """ - insert into glosso.entitlements - (user_id, plan, status, source, expires_at, started_at, auto_renew, - provider, provider_token_hash, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, now()) - on conflict (user_id) do update set - plan = excluded.plan, - status = excluded.status, - source = excluded.source, - expires_at = excluded.expires_at, - started_at = excluded.started_at, - auto_renew = excluded.auto_renew, - provider = excluded.provider, - provider_token_hash = excluded.provider_token_hash, - updated_at = now() - """.trimIndent(), - ).use { statement -> - statement.setObject(1, entitlement.userId) - statement.setString(2, entitlement.plan) - statement.setString(3, entitlement.status) - statement.setString(4, entitlement.source) - statement.setTimestamp(5, entitlement.expiresAt?.let(Timestamp::from)) - statement.setTimestamp(6, Timestamp.from(entitlement.startedAt)) - statement.setBoolean(7, entitlement.autoRenew) - statement.setString(8, entitlement.provider) - statement.setString(9, entitlement.providerTokenHash) - statement.executeUpdate() - } - } - } - - override fun lifetimeGrants(userId: UUID): List { - dataSource.connection.use { connection -> - connection.prepareStatement( - """ - select user_id, plan, source, provider, granted_at - from glosso.lifetime_grants - where user_id = ? and revoked_at is null - order by granted_at - """.trimIndent(), - ).use { statement -> - statement.setObject(1, userId) - statement.executeQuery().use { rs -> - val grants = mutableListOf() - while (rs.next()) { - grants += LifetimeGrant( - userId = rs.getObject("user_id", UUID::class.java), - plan = rs.getString("plan"), - source = rs.getString("source"), - provider = rs.getString("provider"), - grantedAt = rs.getTimestamp("granted_at").toInstant(), - ) - } - return grants - } - } - } - } - - override fun grantLifetime( - userId: UUID, - plan: String, - source: String, - grantedAt: Instant, - provider: String?, - providerRefHash: String?, - ) { - dataSource.connection.use { connection -> - connection.prepareStatement( - """ - insert into glosso.lifetime_grants - (user_id, plan, source, provider, provider_ref_hash, granted_at) - values (?, ?, ?, ?, ?, ?) - on conflict (user_id, plan, source) do nothing - """.trimIndent(), - ).use { statement -> - statement.setObject(1, userId) - statement.setString(2, plan) - statement.setString(3, source) - statement.setString(4, provider) - statement.setString(5, providerRefHash) - statement.setTimestamp(6, Timestamp.from(grantedAt)) - statement.executeUpdate() - } - } - } - // Lock order for the account flows: the `users` rows first, all at once and // sorted (lockUsers), and only then external links. Sign-in takes its // advisory lock before any of that and never while holding a row. @@ -281,7 +118,7 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor return@transaction ExternalSignIn.SignedIn(loadIdentity(connection, deviceUserId), moved = false) } - moveAccountToDevice(connection, linkedUserId, deviceUserId, deviceId, now) + moveAccountToDevice(connection, linkedUserId, deviceUserId, deviceId) touchLink(connection, provider, subject, now) ExternalSignIn.SignedIn(loadIdentity(connection, linkedUserId), moved = true) } @@ -299,7 +136,7 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor /** * Moves the account [accountUserId] to [deviceId], merging the device's own * placeholder row [placeholderId] (if any) into the account first; see the - * account move on [EntitlementStore]. Runs inside the caller's transaction, + * account move on [AccountStore]. Runs inside the caller's transaction, * which already holds the users locks of both rows. */ private fun moveAccountToDevice( @@ -307,10 +144,8 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor accountUserId: UUID, placeholderId: UUID?, deviceId: String, - now: Instant, ) { if (placeholderId != null && placeholderId != accountUserId) { - keepHighestEntitlement(connection, accountUserId, placeholderId, now) MERGE_STATEMENTS.forEach { sql -> val names = MERGE_PARAMETER.findAll(sql).map { it.value }.toList() connection.prepareStatement(sql.replace(MERGE_PARAMETER, "?")).use { statement -> @@ -347,26 +182,6 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor } } - /** - * Keeps, of the two users' entitlement rows, the highest by - * [entitlementMergeOrder] on the account; the other one goes away with the - * placeholder. On a tie the account keeps its own. - */ - private fun keepHighestEntitlement(connection: Connection, accountUserId: UUID, placeholderId: UUID, now: Instant) { - val placeholderRow = entitlement(connection, placeholderId) ?: return - val accountRow = entitlement(connection, accountUserId) - if (accountRow != null && entitlementMergeOrder(now).compare(accountRow, placeholderRow) >= 0) return - connection.prepareStatement("delete from glosso.entitlements where user_id = ?").use { statement -> - statement.setObject(1, accountUserId) - statement.executeUpdate() - } - connection.prepareStatement("update glosso.entitlements set user_id = ? where user_id = ?").use { statement -> - statement.setObject(1, accountUserId) - statement.setObject(2, placeholderId) - statement.executeUpdate() - } - } - /** Locks the users rows in a fixed order, so two account flows can never deadlock. */ private fun lockUsers(connection: Connection, vararg userIds: UUID) { val missing = tryLockUsers(connection, userIds.toList()) @@ -436,8 +251,7 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor private fun loadIdentity(connection: Connection, userId: UUID): UserIdentity = connection.prepareStatement( """ - select id, device_id, created_at, beta_founder, beta_founder_since, - beta_founder_until, beta_founder_benefit + select id, device_id, created_at from glosso.users where id = ? """.trimIndent(), ).use { statement -> @@ -457,22 +271,6 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor id = getObject("id", UUID::class.java), deviceId = getString("device_id"), createdAt = getTimestamp("created_at").toInstant(), - betaFounder = getBoolean("beta_founder"), - betaFounderSince = getTimestamp("beta_founder_since")?.toInstant(), - betaFounderUntil = getTimestamp("beta_founder_until")?.toInstant(), - betaFounderBenefit = getString("beta_founder_benefit"), - ) - - private fun ResultSet.toEntitlement(): StoredEntitlement = StoredEntitlement( - userId = getObject("user_id", UUID::class.java), - plan = getString("plan"), - status = getString("status"), - source = getString("source"), - expiresAt = getTimestamp("expires_at")?.toInstant(), - startedAt = getTimestamp("started_at").toInstant(), - autoRenew = getBoolean("auto_renew"), - provider = getString("provider"), - providerTokenHash = getString("provider_token_hash"), ) private companion object { @@ -485,7 +283,8 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor * bound to `:account` and `:placeholder`; see [moveAccountToDevice]. * Where both sides hold the same key the most recently updated row wins; * the rest of the placeholder's rows are re-parented, never deleted. - * The entitlement row is settled before, by [keepHighestEntitlement]. + * Rows left from the removed paid tier (entitlements, lifetime grants, + * Beta Founder) are carried over too, so a move never drops them. */ val MERGE_STATEMENTS = listOf( // Learner profiles, one per language. @@ -538,6 +337,13 @@ class JdbcEntitlementStore(private val dataSource: DataSource) : EntitlementStor ) """, "update glosso.user_needs set user_id = :account where user_id = :placeholder", + // Legacy paid-tier rows: kept, never written any more. + """ + update glosso.entitlements p set user_id = :account + where p.user_id = :placeholder and not exists ( + select 1 from glosso.entitlements a where a.user_id = :account + ) + """, // Lifetime grants the account does not hold yet. """ update glosso.lifetime_grants p set user_id = :account diff --git a/server/src/main/kotlin/com/glosso/server/product/ProductConfig.kt b/server/src/main/kotlin/com/glosso/server/product/ProductConfig.kt index e73b86b..9145061 100644 --- a/server/src/main/kotlin/com/glosso/server/product/ProductConfig.kt +++ b/server/src/main/kotlin/com/glosso/server/product/ProductConfig.kt @@ -1,49 +1,8 @@ package com.glosso.server.product -import com.glosso.server.api.BetaDto import com.glosso.server.api.ClientConfigDto -import com.glosso.server.api.FounderBenefitDto import com.glosso.server.api.ProductConfigResponse -import io.github.oshai.kotlinlogging.KotlinLogging -import java.time.Instant -import java.time.format.DateTimeFormatter import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -private val logger = KotlinLogging.logger("GlossoProductConfig") - -/** The active phase of the product. `PRO`/`PERSONALIZED` are plans, not phases. */ -enum class ProductMode { BETA, FREE } - -/** - * The Beta Founder benefit. [kind] `PRO_LIFETIME` (the default) grants PRO for - * life as a one-time lifetime grant, never PERSONALIZED; any other kind grants - * no plan. [until] is kept for the contract shape and is null: the benefit never - * expires. - */ -data class FounderBenefitConfig( - val kind: String?, - val value: Int?, - val until: Instant?, - val label: String?, -) { - val grantsProLifetime: Boolean get() = kind.equals(ProductConfig.PRO_LIFETIME, ignoreCase = true) -} - -/** - * Two independent keys that happen to share their default. [endDate] closes the - * free beta; [founderUntil] closes the Beta Founder window (every alta on or - * before it is a founder). Neither controls the other. - */ -data class BetaConfig( - val endDate: Instant, - val founderEnabled: Boolean, - val founderBenefit: FounderBenefitConfig, - val founderUntil: Instant = ProductConfig.DEFAULT_FOUNDER_UNTIL, -) data class ClientConfig( val minimumSupportedVersion: Int, @@ -52,67 +11,17 @@ data class ClientConfig( ) /** - * The single source of product configuration, read once from the environment in - * [fromEnv] and served by `GET /v1/config`. Defaults are safe: the beta is on, - * the founder benefit is not priced, and nobody is force-updated. - * - * Frozen environment names and defaults live in - * docs/PRODUCT-CONFIG-CONTRACT.md section 6. `limits` and `pricing` default to - * null ("not decided yet") so no figure is ever invented in code. - * - * [mode] is the configured phase; the served phase also honours the calendar: - * past [BetaConfig.endDate] the beta is over even if nobody edited the mode. + * The product configuration, read once from the environment in [fromEnv] and + * served by `GET /v1/config`: feature flags and the client version policy. + * Glosso is free for everyone, so there are no plans, prices or phases. + * Environment names live in docs/PRODUCT-CONFIG-CONTRACT.md. */ data class ProductConfig( - val mode: ProductMode, - val beta: BetaConfig, - /** Explicit flags. `beta_banner`, when absent, follows whether the beta is active. */ val flags: Map, val client: ClientConfig, - val limits: JsonElement? = null, - val pricing: JsonElement? = null, - val plans: Map = DEFAULT_PLANS, ) { - /** True while the free beta is open: configured as BETA and not past the end date. */ - fun betaActiveAt(now: Instant): Boolean = mode == ProductMode.BETA && !now.isAfter(beta.endDate) - - fun modeAt(now: Instant): ProductMode = if (betaActiveAt(now)) ProductMode.BETA else ProductMode.FREE - - /** An alta on or before [BetaConfig.founderUntil] is a founder; the beta end date plays no part. */ - fun isFounderAlta(createdAt: Instant): Boolean = beta.founderEnabled && !createdAt.isAfter(beta.founderUntil) - - fun flagsAt(now: Instant): Map { - val served = linkedMapOf("beta_banner" to (flags["beta_banner"] ?: betaActiveAt(now))) - served.putAll(flags) - return served - } - - /** - * What a plan includes. While the beta is open everybody gets the full - * access of the top plan, free and whatever their own plan is; the flags - * still decide which of those features exist yet. - */ - fun entitlementsFor(plan: String, now: Instant): JsonObject = - if (betaActiveAt(now)) plans.getValue(TOP_PLAN) else plans[plan] ?: plans.getValue("FREE") - - fun toResponse(now: Instant = Instant.now()): ProductConfigResponse = ProductConfigResponse( - mode = modeAt(now).name, - beta = BetaDto( - active = betaActiveAt(now), - endDate = ISO.format(beta.endDate), - founderEnabled = beta.founderEnabled, - founderBenefit = FounderBenefitDto( - kind = beta.founderBenefit.kind, - value = beta.founderBenefit.value, - until = beta.founderBenefit.until?.let(ISO::format), - label = beta.founderBenefit.label, - ), - founderUntil = ISO.format(beta.founderUntil), - ), - plans = plans, - limits = limits, - pricing = pricing, - flags = flagsAt(now), + fun toResponse(): ProductConfigResponse = ProductConfigResponse( + flags = flags, client = ClientConfigDto( minimumSupportedVersion = client.minimumSupportedVersion, recommendedVersion = client.recommendedVersion, @@ -120,67 +29,19 @@ data class ProductConfig( ), ) - fun toJson(now: Instant = Instant.now()): String = - CONFIG_JSON.encodeToString(ProductConfigResponse.serializer(), toResponse(now)) + fun toJson(): String = CONFIG_JSON.encodeToString(ProductConfigResponse.serializer(), toResponse()) companion object { - val DEFAULT_END_DATE: Instant = Instant.parse("2027-12-31T23:59:59Z") - val DEFAULT_FOUNDER_UNTIL: Instant = Instant.parse("2027-12-31T23:59:59Z") const val DEFAULT_MIN_VERSION = 2210 const val DEFAULT_RECOMMENDED_VERSION = 2219 - const val PRO_LIFETIME = "PRO_LIFETIME" - const val DEFAULT_FOUNDER_KIND = PRO_LIFETIME - const val DEFAULT_FOUNDER_LABEL = "Glosso Pro gratis para siempre" - /** The plan whose access the open beta grants to everybody. */ - const val TOP_PLAN = "PERSONALIZED" - - private val ISO: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT private val CONFIG_JSON = Json { encodeDefaults = true } - private fun plan( - pronunciationAnalysis: String, - exercises: String, - analytics: String, - personalization: String, - personalizedLearning: Boolean, - ) = JsonObject( - linkedMapOf( - "pronunciation_analysis" to JsonPrimitive(pronunciationAnalysis), - "exercises" to JsonPrimitive(exercises), - "analytics" to JsonPrimitive(analytics), - "personalization" to JsonPrimitive(personalization), - "personalized_learning" to JsonPrimitive(personalizedLearning), - ), - ) - - val DEFAULT_PLANS: Map = linkedMapOf( - "FREE" to plan("LIMITED", "LIMITED", "BASIC", "NONE", false), - "PRO" to plan("UNLIMITED", "EXTENDED", "ADVANCED", "NONE", false), - "PERSONALIZED" to plan("UNLIMITED", "UNLIMITED", "ADVANCED", "FULL", true), - ) - private const val FLAG_PREFIX = "GLOSSO_FLAG_" fun fromEnv(env: Map = System.getenv()): ProductConfig { - val mode = when (env.value("GLOSSO_PRODUCT_MODE")?.uppercase()) { - "FREE" -> ProductMode.FREE - else -> ProductMode.BETA - } val minVersion = env.value("GLOSSO_MIN_CLIENT_VERSION")?.toIntOrNull() ?: DEFAULT_MIN_VERSION return ProductConfig( - mode = mode, - beta = BetaConfig( - endDate = parseInstant(env, "GLOSSO_BETA_END_DATE") ?: DEFAULT_END_DATE, - founderEnabled = parseBoolean(env.value("GLOSSO_BETA_FOUNDER_ENABLED"), default = true), - founderBenefit = FounderBenefitConfig( - kind = env.value("GLOSSO_BETA_FOUNDER_BENEFIT_KIND") ?: DEFAULT_FOUNDER_KIND, - value = env.value("GLOSSO_BETA_FOUNDER_BENEFIT_VALUE")?.toIntOrNull(), - until = null, - label = env.value("GLOSSO_BETA_FOUNDER_BENEFIT_LABEL") ?: DEFAULT_FOUNDER_LABEL, - ), - founderUntil = parseInstant(env, "GLOSSO_BETA_FOUNDER_UNTIL") ?: DEFAULT_FOUNDER_UNTIL, - ), flags = parseFlags(env), client = ClientConfig( minimumSupportedVersion = minVersion, @@ -191,69 +52,17 @@ data class ProductConfig( ), forceUpdate = parseBoolean(env.value("GLOSSO_FORCE_UPDATE"), default = false), ), - limits = parseJsonOrNull(env["GLOSSO_LIMITS_JSON"]), - pricing = parseJsonOrNull(env["GLOSSO_PRICING_JSON"]), - plans = parsePlans(env["GLOSSO_PLANS_JSON"]), ) } /** A blank variable is the same as a missing one: docker compose injects `""` for unset values. */ private fun Map.value(name: String): String? = this[name]?.trim()?.takeIf { it.isNotEmpty() } - private fun parseInstant(env: Map, name: String): Instant? { - val raw = env.value(name) ?: return null - return runCatching { Instant.parse(raw) }.getOrElse { - logger.warn { "$name '$raw' is not an ISO-8601 instant; using the default" } - null - } - } - - /** - * Per-plan limits and prices are served as the operator writes them, so - * the shape can be fixed from configuration without an app release and - * without the server guessing any numbers. Unset (or unparseable) means - * "not decided yet" and is served as null. - */ - private fun parseJsonOrNull(raw: String?): JsonElement? { - val text = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null - return runCatching { Json.parseToJsonElement(text) }.getOrNull() - } - - /** - * `GLOSSO_PLANS_JSON` overrides the entitlement matrix key by key on top - * of the defaults: a value can change and a new entitlement can be added, - * but a key the operator does not mention keeps its default, so an old - * app that expects the four original keys never sees them disappear. - * Only the three known plans exist; any other top-level key is ignored. - */ - private fun parsePlans(raw: String?): Map { - val overrides = parseJsonOrNull(raw) as? JsonObject ?: run { - if (!raw.isNullOrBlank()) logger.warn { "GLOSSO_PLANS_JSON is not a JSON object; using the defaults" } - return DEFAULT_PLANS - } - return DEFAULT_PLANS.mapValues { (name, defaults) -> - val override = overrides[name] as? JsonObject ?: return@mapValues defaults - JsonObject(defaults + override.filterValues { it !is JsonNull }) - } - } - - private fun parseFlags(env: Map): Map { - val flags = linkedMapOf( - "pro" to false, - "personalized" to false, - "accounts" to false, - "adaptive_exercises" to false, - "advanced_analytics" to false, - ) - val overrides = env.entries + private fun parseFlags(env: Map): Map = + env.entries .filter { it.key.startsWith(FLAG_PREFIX) && it.key.length > FLAG_PREFIX.length && it.value.isNotBlank() } .associate { it.key.removePrefix(FLAG_PREFIX).lowercase() to parseBoolean(it.value, default = false) } - for ((flag, enabled) in overrides) { - if (flag in flags) flags[flag] = enabled - } - overrides.keys.filter { it !in flags }.sorted().forEach { flags[it] = overrides.getValue(it) } - return flags - } + .toSortedMap() private fun parseBoolean(raw: String?, default: Boolean): Boolean = when (raw?.trim()?.lowercase()) { "true", "1", "yes", "on" -> true diff --git a/server/src/test/kotlin/com/glosso/server/BillingRouteTest.kt b/server/src/test/kotlin/com/glosso/server/BillingRouteTest.kt deleted file mode 100644 index b8e0701..0000000 --- a/server/src/test/kotlin/com/glosso/server/BillingRouteTest.kt +++ /dev/null @@ -1,426 +0,0 @@ -package com.glosso.server - -import com.glosso.server.api.BillingVerifyRequest -import com.glosso.server.api.PubSubMessage -import com.glosso.server.api.PubSubPushEnvelope -import com.glosso.server.billing.BillingConfig -import com.glosso.server.billing.BillingService -import com.glosso.server.billing.NotificationType -import com.glosso.server.billing.PlayApiException -import com.glosso.server.billing.PlayLineItem -import com.glosso.server.billing.PlaySubscription -import com.glosso.server.billing.PlaySubscriptionsClient -import com.glosso.server.billing.UnavailablePlaySubscriptionsClient -import com.glosso.server.product.InMemoryEntitlementStore -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.testing.testApplication -import java.io.File -import java.nio.file.Files -import java.util.Base64 -import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Verifies the server billing endpoints against a fake Play API. The real API is - * never called, so these lock the mapping, the cache behaviour, the fail-closed - * answer when Play (or the config) is unavailable, and the RTDN flow. See - * docs/BILLING-E2E.md. - */ -class BillingRouteTest { - - private val packageName = "me.shirobyte42.glosso" - private val productId = "com.glosso.app.pro" - private val token = "purchase-token" - private val rtdnSecret = "rtdn-secret" - private val future = System.currentTimeMillis() + 86_400_000L - - private class FakePlayClient( - @Volatile var purchase: PlaySubscription?, - @Volatile var failure: PlayApiException? = null, - ) : PlaySubscriptionsClient { - override val configured: Boolean = true - val calls = AtomicInteger(0) - - override suspend fun getSubscription(packageName: String, purchaseToken: String): PlaySubscription? { - calls.incrementAndGet() - failure?.let { throw it } - return purchase - } - } - - private fun purchase( - state: String, - expiry: Long? = future, - product: String = productId, - ) = PlaySubscription( - state = state, - lineItems = listOf(PlayLineItem(product, expiry, autoRenewing = true, offerId = null)), - cancelTimeMillis = null, - acknowledgeState = "ACKNOWLEDGED", - ) - - private fun service( - client: PlaySubscriptionsClient, - secret: String? = rtdnSecret, - subscription: String? = null, - cacheTtlMillis: Long = 600_000, - ) = BillingService( - client = client, - config = BillingConfig( - packageName = packageName, - productId = productId, - trialOfferId = null, - cacheTtlMillis = cacheTtlMillis, - rtdnSecret = secret, - rtdnSubscription = subscription, - ), - ) - - private fun request(purchaseToken: String = token, packageName: String = this.packageName) = - BillingVerifyRequest(productId = productId, purchaseToken = purchaseToken, packageName = packageName) - - private fun emptyAssets(): File = Files.createTempDirectory("glosso-billing-assets").toFile() - - private fun envelope(subscriptionJson: String): PubSubPushEnvelope = PubSubPushEnvelope( - message = PubSubMessage(data = Base64.getEncoder().encodeToString(subscriptionJson.toByteArray())), - subscription = "projects/glosso/subscriptions/glosso-rtdn", - ) - - private fun notification(type: Int, purchaseToken: String = token): String = - """{"version":"1.0","packageName":"$packageName","subscriptionNotification":""" + - """{"version":"1.0","notificationType":$type,"purchaseToken":"$purchaseToken","subscriptionId":"$productId"}}""" - - private fun canonicalHash(raw: String): String = - java.security.MessageDigest.getInstance("SHA-256") - .digest(raw.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - - @Test - fun `valid active subscription is entitled`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - - assertEquals(HttpStatusCode.OK, response.status) - val body = response.bodyAsText() - assertTrue(body.contains("\"entitled\": true"), body) - assertTrue(body.contains("\"status\": \"ACTIVE\""), body) - assertTrue(body.contains("\"expiresAtMillis\": $future"), body) - } - - @Test - fun `expired subscription is not entitled`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_EXPIRED", expiry = future - 7 * 86_400_000L)) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val body = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - }.bodyAsText() - - assertTrue(body.contains("\"entitled\": false"), body) - assertTrue(body.contains("\"status\": \"NONE\""), body) - } - - @Test - fun `revoked purchase is not entitled`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_EXPIRED", expiry = null)) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val body = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - }.bodyAsText() - - assertTrue(body.contains("\"entitled\": false"), body) - assertTrue(body.contains("\"status\": \"NONE\""), body) - } - - @Test - fun `unknown token is not entitled, not an error`() = testApplication { - val fake = FakePlayClient(purchase = null) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - - assertEquals(HttpStatusCode.OK, response.status) - assertTrue(response.bodyAsText().contains("\"entitled\": false")) - } - - @Test - fun `a verified verdict is served from cache without asking Play again`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - repeat(2) { - json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - } - - assertEquals(1, fake.calls.get()) - } - - @Test - fun `a cache TTL of zero always asks Play`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { - module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake, cacheTtlMillis = 0)) - } - val json = createClient { install(ContentNegotiation) { json() } } - - repeat(2) { - json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - } - - assertEquals(2, fake.calls.get()) - } - - @Test - fun `without a service account verification says it cannot verify, never Pro`() = testApplication { - val billing = BillingService( - client = UnavailablePlaySubscriptionsClient, - config = BillingConfig(packageName, productId, null, 600_000, rtdnSecret, null), - ) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = billing) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - - assertEquals(HttpStatusCode.ServiceUnavailable, response.status) - assertFalse(response.bodyAsText().contains("entitled")) - } - - @Test - fun `when Play fails the server rejects instead of granting Pro`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE"), failure = PlayApiException("boom", 500)) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - - assertEquals(HttpStatusCode.ServiceUnavailable, response.status) - } - - @Test - fun `a request for another package is refused`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request(packageName = "com.glosso.app")) - } - - assertEquals(HttpStatusCode.BadRequest, response.status) - assertEquals(0, fake.calls.get()) - } - - @Test - fun `rtdn without a secret is closed`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { - module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake, secret = null)) - } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - setBody(envelope(notification(NotificationType.CANCELED))) - } - - assertEquals(HttpStatusCode.ServiceUnavailable, response.status) - } - - @Test - fun `rtdn with the wrong secret is unauthorized`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer wrong") - setBody(envelope(notification(NotificationType.CANCELED))) - } - - assertEquals(HttpStatusCode.Unauthorized, response.status) - } - - @Test - fun `rtdn ignores a token in the query string, the header is the only way`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - // A secret in the URL ends up in every proxy access log; it must not - // authenticate even when it is correct (SEC-08). - val response = json.post("/v1/billing/rtdn?token=$rtdnSecret") { - contentType(ContentType.Application.Json) - setBody(envelope(notification(NotificationType.CANCELED))) - } - - assertEquals(HttpStatusCode.Unauthorized, response.status) - } - - @Test - fun `rtdn invalidates the cache and refreshes the state`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - assertEquals(1, fake.calls.get()) - - // Play now reports the subscription as expired; the RTDN must pick it up. - fake.purchase = purchase("SUBSCRIPTION_STATE_EXPIRED", expiry = null) - val webhook = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $rtdnSecret") - setBody(envelope(notification(NotificationType.EXPIRED))) - } - assertEquals(HttpStatusCode.OK, webhook.status) - assertEquals(2, fake.calls.get()) - - val body = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - }.bodyAsText() - assertTrue(body.contains("\"entitled\": false"), body) - assertEquals(2, fake.calls.get()) - } - - @Test - fun `a revoked notification denies entitlement without calling Play`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - } - - val webhook = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $rtdnSecret") - setBody(envelope(notification(NotificationType.REVOKED))) - } - assertEquals(HttpStatusCode.OK, webhook.status) - - val body = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(request()) - }.bodyAsText() - assertTrue(body.contains("\"entitled\": false"), body) - assertEquals(1, fake.calls.get()) - } - - @Test - fun `a token with surrounding spaces stores the canonical hash so RTDN can revoke`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = service(fake), - entitlementStore = store, - ) - } - val json = createClient { install(ContentNegotiation) { json() } } - - // The client sends a token padded with spaces; BillingService validates - // its trim(), so the persisted hash must be that same canonical token or - // the RTDN lookup below misses the row and a refund never revokes it. - val padded = request(purchaseToken = " $token ").copy(deviceId = "device-1") - val verified = json.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(padded) - } - assertEquals(HttpStatusCode.OK, verified.status) - assertTrue(verified.bodyAsText().contains("\"entitled\": true")) - assertEquals(canonicalHash(token), store.entitlementOf("device-1")?.providerTokenHash) - - // The RTDN carries the canonical token; it has to find and revoke the row. - val webhook = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $rtdnSecret") - setBody(envelope(notification(NotificationType.REVOKED))) - } - assertEquals(HttpStatusCode.OK, webhook.status) - val revoked = store.entitlementOf("device-1") - assertEquals("NONE", revoked?.status) - assertEquals("FREE", revoked?.plan) - } - - @Test - fun `rtdn with an unreadable payload is rejected`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val response = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $rtdnSecret") - setBody(PubSubPushEnvelope(PubSubMessage(data = "!!not-base64!!"))) - } - - assertEquals(HttpStatusCode.BadRequest, response.status) - } - - @Test - fun `rtdn for another package is rejected`() = testApplication { - val fake = FakePlayClient(purchase("SUBSCRIPTION_STATE_ACTIVE")) - application { module(emptyAssets(), adminToken = null, analytics = null, billing = service(fake)) } - val json = createClient { install(ContentNegotiation) { json() } } - - val otherPackage = """{"packageName":"com.example.other","testNotification":{"version":"1.0"}}""" - val response = json.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer $rtdnSecret") - setBody(envelope(otherPackage)) - } - - assertEquals(HttpStatusCode.BadRequest, response.status) - } -} diff --git a/server/src/test/kotlin/com/glosso/server/GoogleSignInRouteTest.kt b/server/src/test/kotlin/com/glosso/server/GoogleSignInRouteTest.kt index 596a8b6..b55a903 100644 --- a/server/src/test/kotlin/com/glosso/server/GoogleSignInRouteTest.kt +++ b/server/src/test/kotlin/com/glosso/server/GoogleSignInRouteTest.kt @@ -2,15 +2,11 @@ package com.glosso.server import com.glosso.server.api.GoogleSignInRequest import com.glosso.server.product.AccountSecret -import com.glosso.server.product.BetaConfig import com.glosso.server.product.ClientConfig -import com.glosso.server.product.EntitlementService -import com.glosso.server.product.FounderBenefitConfig import com.glosso.server.product.GoogleTokens -import com.glosso.server.product.InMemoryEntitlementStore +import com.glosso.server.product.InMemoryAccountStore import com.glosso.server.product.NimbusGoogleIdTokenVerifier import com.glosso.server.product.ProductConfig -import com.glosso.server.product.ProductMode import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get @@ -28,6 +24,7 @@ import java.nio.file.Files import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.serialization.json.Json @@ -38,8 +35,7 @@ import kotlinx.serialization.json.jsonPrimitive /** * Sign in with Google end to end through HTTP: nonce, a signed ID token (with * a local key standing in for Google's) and the account following the person - * to a new phone. The founder window is closed, so a founder status on the new - * phone can only come from the sign-in. + * to a new phone. */ class GoogleSignInRouteTest { @@ -47,38 +43,22 @@ class GoogleSignInRouteTest { private val secret = AccountSecret(ByteArray(32) { 4 }) private val verifier = NimbusGoogleIdTokenVerifier(setOf(GoogleTokens.CLIENT_ID), GoogleTokens.keys(google)) - private val config = ProductConfig( - mode = ProductMode.FREE, - beta = BetaConfig( - endDate = ProductConfig.DEFAULT_END_DATE, - founderEnabled = true, - founderBenefit = FounderBenefitConfig( - kind = ProductConfig.PRO_LIFETIME, - value = null, - until = null, - label = ProductConfig.DEFAULT_FOUNDER_LABEL, - ), - founderUntil = Instant.parse("2020-01-01T00:00:00Z"), - ), - flags = emptyMap(), - client = ClientConfig(2210, 2219, false), - ) - - /** A founder made while the window was open, on "old-phone". */ - private fun founderStore(): InMemoryEntitlementStore { - val store = InMemoryEntitlementStore() - val open = config.copy(beta = config.beta.copy(founderUntil = ProductConfig.DEFAULT_FOUNDER_UNTIL)) - EntitlementService(store, open, clock = { Instant.parse("2026-03-01T00:00:00Z") }).identify("old-phone") + private val config = ProductConfig(flags = emptyMap(), client = ClientConfig(2210, 2219, false)) + + /** An account first seen on "old-phone". */ + private fun storeWithOldPhone(): InMemoryAccountStore { + val store = InMemoryAccountStore() + store.upsertUser("old-phone", Instant.parse("2026-03-01T00:00:00Z")) return store } - private fun ApplicationTestBuilder.app(store: InMemoryEntitlementStore, withGoogle: Boolean = true) { + private fun ApplicationTestBuilder.app(store: InMemoryAccountStore, withGoogle: Boolean = true) { application { module( Files.createTempDirectory("glosso-google-assets").toFile(), adminToken = null, analytics = null, - entitlementStore = store, + accountStore = store, productConfig = config, accountSecret = secret, googleVerifier = verifier.takeIf { withGoogle }, @@ -103,8 +83,9 @@ class GoogleSignInRouteTest { } @Test - fun `the founder signs in with Google on a new phone and keeps PRO for life`() = testApplication { - val store = founderStore() + fun `signing in with Google on a new phone moves the account there`() = testApplication { + val store = storeWithOldPhone() + val account = assertNotNull(store.identityOf("old-phone")) app(store) val client = createClient { install(ContentNegotiation) { json() } } @@ -115,12 +96,10 @@ class GoogleSignInRouteTest { val moved = signIn(client, "new-phone", GoogleTokens.sign(google, "sub-1", nonce(client, "new-phone"))) assertEquals(HttpStatusCode.OK, moved.status, moved.bodyAsText()) val body = moved.bodyAsText() - assertTrue(body.contains("\"betaFounder\": true"), body) - assertTrue(body.contains("\"plan\": \"PRO\""), body) - assertTrue(body.contains("\"planSource\": \"LIFETIME\""), body) assertEquals("no-store", moved.headers["Cache-Control"]) // No personal data from the token reaches the answer. assertTrue("learner@example.com" !in body, body) + assertEquals(account.id, store.identityOf("new-phone")?.id) assertNull(store.identityOf("old-phone")) assertEquals(listOf("google"), linkedAccounts(body)) // The old phone is a fresh anonymous user now: /v1/me says it is linked @@ -131,7 +110,7 @@ class GoogleSignInRouteTest { @Test fun `a token minted for another device is refused`() = testApplication { - app(founderStore()) + app(storeWithOldPhone()) val client = createClient { install(ContentNegotiation) { json() } } val stolen = GoogleTokens.sign(google, "sub-1", nonce(client, "old-phone")) @@ -144,7 +123,7 @@ class GoogleSignInRouteTest { @Test fun `a phone linked to one Google account answers conflict for another`() = testApplication { - app(founderStore()) + app(storeWithOldPhone()) val client = createClient { install(ContentNegotiation) { json() } } signIn(client, "old-phone", GoogleTokens.sign(google, "sub-1", nonce(client, "old-phone"))) @@ -155,7 +134,7 @@ class GoogleSignInRouteTest { @Test fun `sign-in is unavailable without a Google client id`() = testApplication { - app(founderStore(), withGoogle = false) + app(storeWithOldPhone(), withGoogle = false) val client = createClient { install(ContentNegotiation) { json() } } assertEquals( @@ -167,7 +146,7 @@ class GoogleSignInRouteTest { @Test fun `sign-in requires a device id and a token`() = testApplication { - app(founderStore()) + app(storeWithOldPhone()) val client = createClient { install(ContentNegotiation) { json() } } assertEquals(HttpStatusCode.BadRequest, client.post("/v1/auth/google/nonce").status) diff --git a/server/src/test/kotlin/com/glosso/server/ProductRouteTest.kt b/server/src/test/kotlin/com/glosso/server/ProductRouteTest.kt index 1b8698f..11a9bfb 100644 --- a/server/src/test/kotlin/com/glosso/server/ProductRouteTest.kt +++ b/server/src/test/kotlin/com/glosso/server/ProductRouteTest.kt @@ -1,155 +1,41 @@ package com.glosso.server -import com.glosso.server.api.BillingVerifyRequest -import com.glosso.server.api.PubSubMessage -import com.glosso.server.api.PubSubPushEnvelope -import com.glosso.server.billing.BillingConfig -import com.glosso.server.billing.BillingService -import com.glosso.server.billing.NotificationType -import com.glosso.server.billing.PlayLineItem -import com.glosso.server.billing.PlaySubscription -import com.glosso.server.billing.PlaySubscriptionsClient -import com.glosso.server.product.BetaConfig import com.glosso.server.product.ClientConfig -import com.glosso.server.product.EntitlementService -import com.glosso.server.product.FounderBenefitConfig -import com.glosso.server.product.InMemoryEntitlementStore -import com.glosso.server.product.PlanSource +import com.glosso.server.product.InMemoryAccountStore import com.glosso.server.product.ProductConfig -import com.glosso.server.product.ProductMode -import com.glosso.server.product.StoredEntitlement import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import io.ktor.server.testing.testApplication import java.io.File +import java.nio.file.Files import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.nio.file.Files -import java.time.Instant -import java.util.Base64 -import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * `GET /v1/config` and `GET /v1/me`: the server is the authority for the phase, - * the beta dates, the plan and the Beta Founder status. These lock the exact - * config shape (with `no-cache` + ETag) and the identity rules, including that a - * founder keeps the status when the plan changes. + * `GET /v1/config` and `GET /v1/me`. These lock the config shape (with + * `no-cache` + ETag), the legacy open-beta keys older app builds need to keep + * every lesson unlocked, and the identity snapshot. */ class ProductRouteTest { - private val packageName = "me.shirobyte42.glosso" - private val productId = "com.glosso.app.pro" - private val future = System.currentTimeMillis() + 86_400_000L - - private class FakePlayClient( - private val purchase: PlaySubscription, - ) : PlaySubscriptionsClient { - override val configured: Boolean = true - override suspend fun getSubscription(packageName: String, purchaseToken: String): PlaySubscription = - purchase - } - - private fun billing( - rtdnSecret: String? = null, - state: String = "SUBSCRIPTION_STATE_ACTIVE", - expiry: Long? = future, - autoRenewing: Boolean = true, - ): BillingService = BillingService( - client = FakePlayClient( - PlaySubscription( - state = state, - lineItems = listOf(PlayLineItem(productId, expiry, autoRenewing = autoRenewing, offerId = null)), - cancelTimeMillis = null, - acknowledgeState = "ACKNOWLEDGED", - ), - ), - config = BillingConfig( - packageName = packageName, - productId = productId, - trialOfferId = null, - cacheTtlMillis = 600_000, - rtdnSecret = rtdnSecret, - rtdnSubscription = null, - ), - ) - - private fun personalized(userId: UUID, tokenHash: String? = null): StoredEntitlement = - StoredEntitlement( - userId = userId, - plan = "PERSONALIZED", - status = "ACTIVE", - source = "manual", - expiresAt = null, - startedAt = Instant.parse("2026-03-01T00:00:00Z"), - autoRenew = false, - provider = null, - providerTokenHash = tokenHash, - ) - - private fun canonicalHash(raw: String): String = - java.security.MessageDigest.getInstance("SHA-256") - .digest(raw.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - - private fun notification(type: Int): PubSubPushEnvelope = PubSubPushEnvelope( - message = PubSubMessage( - data = Base64.getEncoder().encodeToString( - ( - """{"version":"1.0","packageName":"$packageName","subscriptionNotification":""" + - """{"version":"1.0","notificationType":$type,"purchaseToken":"purchase-token",""" + - """"subscriptionId":"$productId"}}""" - ).toByteArray(), - ), - ), - ) - - private fun config( - mode: ProductMode = ProductMode.BETA, - endDate: Instant = ProductConfig.DEFAULT_END_DATE, - founderEnabled: Boolean = true, - founderUntil: Instant = ProductConfig.DEFAULT_FOUNDER_UNTIL, - founderBenefitKind: String = ProductConfig.PRO_LIFETIME, - ): ProductConfig = ProductConfig( - mode = mode, - beta = BetaConfig( - endDate = endDate, - founderEnabled = founderEnabled, - founderBenefit = FounderBenefitConfig( - kind = founderBenefitKind, - value = null, - until = null, - label = ProductConfig.DEFAULT_FOUNDER_LABEL, - ), - founderUntil = founderUntil, - ), - flags = emptyMap(), + private fun config(): ProductConfig = ProductConfig( + flags = mapOf("accounts" to true), client = ClientConfig(2210, 2219, false), ) - private companion object { - val PAST: Instant = Instant.parse("2020-01-01T00:00:00Z") - val FUTURE: Instant = Instant.parse("2099-12-31T23:59:59Z") - } - private fun emptyAssets(): File = Files.createTempDirectory("glosso-product-assets").toFile() @Test @@ -164,411 +50,57 @@ class ProductRouteTest { assertEquals(HttpStatusCode.OK, response.status) assertEquals("no-cache", response.headers[HttpHeaders.CacheControl]) assertFalse(response.headers[HttpHeaders.ETag].isNullOrBlank()) - val body = response.bodyAsText() - assertTrue(body.contains("\"mode\":\"BETA\""), body) - assertTrue(body.contains("\"limits\":null"), body) - assertTrue(body.contains("\"pricing\":null"), body) - assertTrue(body.contains("\"forceUpdate\":false"), body) + val body = Json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals(true, body.getValue("flags").jsonObject.getValue("accounts").jsonPrimitive.boolean) + val versions = body.getValue("client").jsonObject + assertEquals("2210", versions.getValue("minimumSupportedVersion").jsonPrimitive.content) + assertEquals("2219", versions.getValue("recommendedVersion").jsonPrimitive.content) + assertFalse("plans" in body || "pricing" in body || "limits" in body, body.toString()) } @Test - fun `config revalidates with the etag`() = testApplication { + fun `config keeps the open beta shape older apps unlock content with`() = testApplication { application { module(emptyAssets(), adminToken = null, analytics = null, productConfig = config()) } val client = createClient { install(ContentNegotiation) { json() } } - val etag = client.get("/v1/config").headers[HttpHeaders.ETag] - assertNotNull(etag) - - val revalidated = client.get("/v1/config") { header(HttpHeaders.IfNoneMatch, etag) } - assertEquals(HttpStatusCode.NotModified, revalidated.status) - } - - @Test - fun `the first identification marks a beta founder, permanently`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val body = client.get("/v1/me") { header("X-Device-Id", "founder-device") }.bodyAsText() - - assertTrue(body.contains("\"plan\": \"PRO\""), body) - assertTrue(body.contains("\"status\": \"NONE\""), body) - assertTrue(body.contains("\"betaFounder\": true"), body) - assertTrue(body.contains("\"betaFounderSince\": \""), body) - assertTrue(body.contains("\"betaFounderBenefit\": \"PRO_LIFETIME\""), body) - assertEquals(true, store.identityOf("founder-device")?.betaFounder) - - // A second call must not recompute it away. - val again = client.get("/v1/me") { header("X-Device-Id", "founder-device") }.bodyAsText() - assertTrue(again.contains("\"betaFounder\": true"), again) - } - - @Test - fun `a user created earlier in the beta is still marked a founder`() = testApplication { - val store = InMemoryEntitlementStore() - store.upsertUser("early-device", Instant.parse("2026-03-01T00:00:00Z")) - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val body = client.get("/v1/me") { header("X-Device-Id", "early-device") }.bodyAsText() - - assertTrue(body.contains("\"betaFounder\": true"), body) - assertEquals(true, store.identityOf("early-device")?.betaFounder) - } - - @Test - fun `a user from the beta is a founder even once the mode is FREE`() = testApplication { - val store = InMemoryEntitlementStore() - store.upsertUser("transition-device", Instant.parse("2026-03-01T00:00:00Z")) - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - productConfig = config( - mode = ProductMode.FREE, - endDate = Instant.parse("2026-06-01T00:00:00Z"), - ), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val body = client.get("/v1/me") { header("X-Device-Id", "transition-device") }.bodyAsText() - - assertTrue(body.contains("\"betaFounder\": true"), body) - assertEquals(true, store.identityOf("transition-device")?.betaFounder) - } - - @Test - fun `a founder does not lose the status when the plan becomes PRO`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(), - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - client.get("/v1/me") { header("X-Device-Id", "upgrade-device") } - - val verify = client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - header("X-Device-Id", "upgrade-device") - setBody( - BillingVerifyRequest( - productId = productId, - purchaseToken = "purchase-token", - packageName = packageName, - deviceId = "upgrade-device", - ), - ) - } - assertEquals(HttpStatusCode.OK, verify.status) - - val me = client.get("/v1/me") { header("X-Device-Id", "upgrade-device") }.bodyAsText() - assertTrue(me.contains("\"plan\": \"PRO\""), me) - assertTrue(me.contains("\"betaFounder\": true"), me) - assertEquals("PRO", store.entitlementOf("upgrade-device")?.plan) - } - - @Test - fun `a revoked RTDN denies entitlement in the store, not only in the cache`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(rtdnSecret = "rtdn-secret"), - entitlementStore = store, - // Past the beta and the founder window: this is about the - // subscription row, not about founder or beta access. - productConfig = config(endDate = PAST, founderUntil = PAST), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val verify = client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody( - BillingVerifyRequest( - productId = productId, - purchaseToken = "purchase-token", - packageName = packageName, - deviceId = "refund-device", - ), - ) - } - assertEquals(HttpStatusCode.OK, verify.status) - assertEquals("PRO", store.entitlementOf("refund-device")?.plan) - - val webhook = client.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer rtdn-secret") - setBody(notification(NotificationType.REVOKED)) - } - assertEquals(HttpStatusCode.OK, webhook.status) - - assertEquals("FREE", store.entitlementOf("refund-device")?.plan) - val me = client.get("/v1/me") { header("X-Device-Id", "refund-device") }.bodyAsText() - assertTrue(me.contains("\"plan\": \"FREE\""), me) - assertTrue(me.contains("\"status\": \"NONE\""), me) - } - - @Test - fun `a paid verify does not downgrade a personalized plan`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(), - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - val identity = store.upsertUser("personalized-device", Instant.parse("2026-03-01T00:00:00Z")).identity - store.upsertEntitlement( - StoredEntitlement( - userId = identity.id, - plan = "PERSONALIZED", - status = "ACTIVE", - source = "manual", - expiresAt = null, - startedAt = Instant.parse("2026-03-01T00:00:00Z"), - autoRenew = false, - provider = null, - providerTokenHash = null, - ), - ) - - val verify = client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody( - BillingVerifyRequest( - productId = productId, - purchaseToken = "purchase-token", - packageName = packageName, - deviceId = "personalized-device", - ), - ) - } + val body = Json.parseToJsonElement(client.get("/v1/config").bodyAsText()).jsonObject - assertEquals(HttpStatusCode.OK, verify.status) - assertEquals("PERSONALIZED", store.entitlementOf("personalized-device")?.plan) + assertEquals("BETA", body.getValue("mode").jsonPrimitive.content) + assertEquals(true, body.getValue("beta").jsonObject.getValue("active").jsonPrimitive.boolean) } @Test - fun `a non-entitled verify does not downgrade a personalized plan`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing( - state = "SUBSCRIPTION_STATE_EXPIRED", - expiry = System.currentTimeMillis() - 1_000L, - ), - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - val identity = store.upsertUser("personalized-device", Instant.parse("2026-03-01T00:00:00Z")).identity - store.upsertEntitlement(personalized(identity.id)) - - val verify = client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody( - BillingVerifyRequest( - productId = productId, - purchaseToken = "expired-token", - packageName = packageName, - deviceId = "personalized-device", - ), - ) - } - - assertEquals(HttpStatusCode.OK, verify.status) - assertTrue(verify.bodyAsText().contains("\"entitled\": false"), verify.bodyAsText()) - assertEquals("PERSONALIZED", store.entitlementOf("personalized-device")?.plan) - assertEquals("manual", store.entitlementOf("personalized-device")?.source) - val me = client.get("/v1/me") { header("X-Device-Id", "personalized-device") }.bodyAsText() - assertTrue(me.contains("\"plan\": \"PERSONALIZED\""), me) - } - - @Test - fun `a revoked RTDN does not revoke a personalized grant`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(rtdnSecret = "rtdn-secret"), - entitlementStore = store, - productConfig = config(), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - val identity = store.upsertUser("personalized-device", Instant.parse("2026-03-01T00:00:00Z")).identity - // A manual grant that a Play verify linked to this purchase: a refund of - // that purchase must not remove the personalization. - store.upsertEntitlement(personalized(identity.id, tokenHash = canonicalHash("purchase-token"))) - - val webhook = client.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer rtdn-secret") - setBody(notification(NotificationType.REVOKED)) - } - assertEquals(HttpStatusCode.OK, webhook.status) - - assertEquals("PERSONALIZED", store.entitlementOf("personalized-device")?.plan) - assertEquals("ACTIVE", store.entitlementOf("personalized-device")?.status) - val me = client.get("/v1/me") { header("X-Device-Id", "personalized-device") }.bodyAsText() - assertTrue(me.contains("\"plan\": \"PERSONALIZED\""), me) - } - - @Test - fun `a revoked RTDN revokes the same token on every device`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(rtdnSecret = "rtdn-secret"), - entitlementStore = store, - // Past the beta and the founder window: this is about the - // subscription row, not about founder or beta access. - productConfig = config(endDate = PAST, founderUntil = PAST), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - val devices = listOf("device-a", "device-b") - - // The same purchase token is verified from two installs; the migration - // deliberately keeps the token index non-unique for multi-device. - for (device in devices) { - val verify = client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody( - BillingVerifyRequest( - productId = productId, - purchaseToken = "purchase-token", - packageName = packageName, - deviceId = device, - ), - ) - } - assertEquals(HttpStatusCode.OK, verify.status) - assertEquals("PRO", store.entitlementOf(device)?.plan) - } - - // A refund is not per-device: every row with that token must be revoked, - // not just the first one the by-token lookup happens to return. - val webhook = client.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer rtdn-secret") - setBody(notification(NotificationType.REVOKED)) - } - assertEquals(HttpStatusCode.OK, webhook.status) - - for (device in devices) { - assertEquals("FREE", store.entitlementOf(device)?.plan) - val me = client.get("/v1/me") { header("X-Device-Id", device) }.bodyAsText() - assertTrue(me.contains("\"plan\": \"FREE\""), me) - assertTrue(me.contains("\"status\": \"NONE\""), me) - } - } - - @Test - fun `no new founders once the founder window closes`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - productConfig = config(mode = ProductMode.FREE, endDate = PAST, founderUntil = PAST), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val body = client.get("/v1/me") { header("X-Device-Id", "late-device") }.bodyAsText() - - assertTrue(body.contains("\"betaFounder\": false"), body) - assertEquals(false, store.identityOf("late-device")?.betaFounder) - } - - @Test - fun `a closed founder window does not mark founders even in beta mode`() = testApplication { - val store = InMemoryEntitlementStore() + fun `config revalidates with the etag`() = testApplication { application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - productConfig = config(mode = ProductMode.BETA, founderUntil = PAST), - ) + module(emptyAssets(), adminToken = null, analytics = null, productConfig = config()) } val client = createClient { install(ContentNegotiation) { json() } } - client.get("/v1/me") { header("X-Device-Id", "late-beta-device") } + val etag = client.get("/v1/config").headers[HttpHeaders.ETag] + assertNotNull(etag) - assertEquals(false, store.identityOf("late-beta-device")?.betaFounder) + val revalidated = client.get("/v1/config") { header(HttpHeaders.IfNoneMatch, etag) } + assertEquals(HttpStatusCode.NotModified, revalidated.status) } @Test - fun `an alta after the beta but inside the founder window is a founder`() = testApplication { - val store = InMemoryEntitlementStore() + fun `me identifies the device and reports only its linked accounts`() = testApplication { + val store = InMemoryAccountStore() application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - // The beta is long over; the founder window (its own key) is not. - productConfig = config(mode = ProductMode.FREE, endDate = PAST, founderUntil = FUTURE), - ) + module(emptyAssets(), adminToken = null, analytics = null, accountStore = store, productConfig = config()) } val client = createClient { install(ContentNegotiation) { json() } } - val me = client.get("/v1/me") { header("X-Device-Id", "production-device") }.bodyAsText() + val response = client.get("/v1/me") { header("X-Device-Id", "some-device") } - assertTrue(me.contains("\"betaFounder\": true"), me) - assertTrue(me.contains("\"plan\": \"PRO\""), me) - assertTrue(me.contains("\"betaAccess\": false"), me) - assertEquals(true, store.identityOf("production-device")?.betaFounder) + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + val body = Json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals(setOf("linkedAccounts"), body.keys) + assertTrue(body.getValue("linkedAccounts").jsonArray.isEmpty()) + assertNotNull(store.identityOf("some-device")) } @Test @@ -578,7 +110,7 @@ class ProductRouteTest { emptyAssets(), adminToken = null, analytics = null, - entitlementStore = InMemoryEntitlementStore(), + accountStore = InMemoryAccountStore(), productConfig = config(), ) } @@ -598,389 +130,4 @@ class ProductRouteTest { val response = client.get("/v1/me") { header("X-Device-Id", "no-store") } assertEquals(HttpStatusCode.ServiceUnavailable, response.status) } - - @Test - fun `an expired paid plan falls back to the founder PRO content without touching the status`() { - val store = InMemoryEntitlementStore() - val service = EntitlementService(store, config(), clock = { Instant.parse("2026-06-01T00:00:00Z") }) - - val identity = service.identify("service-device") - assertTrue(identity.betaFounder) - store.upsertEntitlement( - StoredEntitlement( - userId = identity.id, - plan = "PRO", - status = "ACTIVE", - source = "play", - expiresAt = Instant.parse("2026-01-01T00:00:00Z"), - startedAt = Instant.parse("2025-01-01T00:00:00Z"), - autoRenew = false, - provider = "google_play", - providerTokenHash = "hash", - ), - ) - - val me = service.me("service-device") - assertEquals("PRO", me.plan) - assertEquals("NONE", me.status) - assertEquals(null, me.expiresAt) - assertTrue(me.betaFounder) - } - - @Test - fun `a beta founder gets PRO content for free and never PERSONALIZED`() { - val store = InMemoryEntitlementStore() - val service = EntitlementService(store, config(), clock = { Instant.parse("2026-06-01T00:00:00Z") }) - - assertTrue(service.identify("founder-device").betaFounder) - val founder = service.me("founder-device") - assertEquals("PRO", founder.plan) - assertEquals("NONE", founder.status) - assertEquals(null, founder.expiresAt) - assertTrue(founder.betaFounder) - - // No entitlement and no founder window: stays FREE. - val afterBeta = EntitlementService( - store, - config(endDate = PAST, founderUntil = PAST), - clock = { Instant.parse("2026-06-01T00:00:00Z") }, - ) - val standard = afterBeta.me("standard-device") - assertEquals("FREE", standard.plan) - assertEquals("NONE", standard.status) - assertFalse(standard.betaFounder) - } - - @Test - fun `the founder status survives the switch to FREE`() { - val store = InMemoryEntitlementStore() - val beta = EntitlementService( - store, - config(mode = ProductMode.BETA, endDate = Instant.parse("2026-06-01T00:00:00Z")), - clock = { Instant.parse("2026-05-01T00:00:00Z") }, - ) - assertTrue(beta.identify("history-device").betaFounder) - - val free = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = Instant.parse("2026-06-01T00:00:00Z")), - clock = { Instant.parse("2027-01-01T00:00:00Z") }, - ) - val me = free.me("history-device") - assertTrue(me.betaFounder) - assertEquals("PRO", me.plan) - } - - @Test - fun `plan and status never contradict each other`() { - val store = InMemoryEntitlementStore() - val service = EntitlementService(store, config(founderEnabled = false), clock = { Instant.parse("2026-06-01T00:00:00Z") }) - - val identity = service.identify("contradiction-device") - store.upsertEntitlement( - StoredEntitlement( - userId = identity.id, - plan = "FREE", - status = "ACTIVE", - source = "manual", - expiresAt = Instant.parse("2027-01-01T00:00:00Z"), - startedAt = Instant.parse("2026-06-01T00:00:00Z"), - autoRenew = false, - provider = null, - providerTokenHash = null, - ), - ) - - val me = service.me("contradiction-device") - assertEquals("FREE", me.plan) - assertEquals("NONE", me.status) - assertEquals(null, me.expiresAt) - } - - private fun json(body: String): JsonObject = Json.parseToJsonElement(body).jsonObject - - private fun JsonObject.bool(key: String): Boolean = getValue(key).jsonPrimitive.boolean - - private fun JsonObject.text(key: String): String = getValue(key).jsonPrimitive.content - - @Test - fun `beta served contract - everything unlocked and the founder marked permanently`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - entitlementStore = store, - // The live defaults: beta and founder window both until 2027-12-31. - productConfig = ProductConfig.fromEnv(emptyMap()), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - val config = json(client.get("/v1/config").bodyAsText()) - assertEquals("BETA", config.text("mode")) - val beta = config.getValue("beta").jsonObject - assertTrue(beta.bool("active")) - assertEquals("2027-12-31T23:59:59Z", beta.text("endDate")) - assertEquals("2027-12-31T23:59:59Z", beta.text("founderUntil")) - assertEquals("PRO_LIFETIME", beta.getValue("founderBenefit").jsonObject.text("kind")) - assertTrue(config.getValue("flags").jsonObject.bool("beta_banner")) - assertFalse(config.getValue("flags").jsonObject.bool("pro")) - assertEquals( - setOf("pronunciation_analysis", "exercises", "analytics", "personalization", "personalized_learning"), - config.getValue("plans").jsonObject.getValue("FREE").jsonObject.keys, - ) - - val me = json(client.get("/v1/me") { header("X-Device-Id", "contract-device") }.bodyAsText()) - assertTrue(me.bool("betaFounder")) - assertTrue(me.bool("betaAccess")) - assertEquals("PRO", me.text("plan")) - assertEquals("LIFETIME", me.text("planSource")) - assertEquals("NONE", me.text("status")) - assertTrue(me["expiresAt"] is JsonNull) - val entitlements = me.getValue("entitlements").jsonObject - assertEquals("UNLIMITED", entitlements.text("pronunciation_analysis")) - assertEquals("UNLIMITED", entitlements.text("exercises")) - assertEquals("ADVANCED", entitlements.text("analytics")) - assertEquals("FULL", entitlements.text("personalization")) - assertTrue(entitlements.bool("personalized_learning")) - assertTrue(me["subscription"] is JsonNull) - - // Permanent: recomputing with a closed window never clears it. - val later = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = PAST, founderUntil = PAST), - ).me("contract-device") - assertTrue(later.betaFounder) - assertEquals("PRO", later.plan) - assertEquals(PlanSource.LIFETIME, later.planSource) - } - - @Test - fun `during the beta a non-founder gets full access too`() { - val store = InMemoryEntitlementStore() - val now = Instant.parse("2026-10-01T00:00:00Z") - val beta = EntitlementService(store, config(founderEnabled = false), clock = { now }) - - val me = beta.me("standard-device") - assertFalse(me.betaFounder) - assertEquals("FREE", me.plan) - assertTrue(me.betaAccess) - assertEquals(ProductConfig.DEFAULT_PLANS.getValue("PERSONALIZED"), me.entitlements) - - // Past the end date the same user falls back to the FREE plan's access. - val after = EntitlementService( - store, - config(founderEnabled = false), - clock = { Instant.parse("2028-01-01T00:00:00Z") }, - ).me("standard-device") - assertFalse(after.betaAccess) - assertEquals(ProductConfig.DEFAULT_PLANS.getValue("FREE"), after.entitlements) - } - - @Test - fun `the beta closes by date even if the mode was never switched`() { - val config = config(mode = ProductMode.BETA, endDate = Instant.parse("2027-12-31T23:59:59Z")) - - val open = json(config.toJson(Instant.parse("2027-12-31T23:59:59Z"))) - assertEquals("BETA", open.text("mode")) - assertTrue(open.getValue("beta").jsonObject.bool("active")) - assertTrue(open.getValue("flags").jsonObject.bool("beta_banner")) - - val closed = json(config.toJson(Instant.parse("2028-01-01T00:00:00Z"))) - assertEquals("FREE", closed.text("mode")) - assertFalse(closed.getValue("beta").jsonObject.bool("active")) - assertFalse(closed.getValue("flags").jsonObject.bool("beta_banner")) - assertNotEquals(open, closed) - } - - @Test - fun `the founder PRO is a lifetime grant, not a subscription, and survives config changes`() { - val store = InMemoryEntitlementStore() - val now = Instant.parse("2027-06-01T00:00:00Z") - EntitlementService(store, config(), clock = { now }).identify("lifetime-device") - val grant = store.grantsOf("lifetime-device").single() - assertEquals("PRO", grant.plan) - assertEquals(EntitlementService.BETA_FOUNDER_SOURCE, grant.source) - - // After the beta, with another benefit configured and the window closed, - // the founder keeps PRO for life: the grant is persisted, not recomputed. - val later = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = PAST, founderUntil = PAST, founderBenefitKind = "discount_percent"), - clock = { Instant.parse("2031-01-01T00:00:00Z") }, - ).me("lifetime-device") - assertEquals("PRO", later.plan) - assertEquals(PlanSource.LIFETIME, later.planSource) - assertEquals("NONE", later.status) - assertEquals(null, later.expiresAt) - assertEquals(null, later.subscription) - assertEquals(ProductConfig.DEFAULT_PLANS.getValue("PRO"), later.entitlements) - } - - @Test - fun `another founder benefit grants no plan`() { - val store = InMemoryEntitlementStore() - val service = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = PAST, founderUntil = FUTURE, founderBenefitKind = "discount_percent"), - clock = { Instant.parse("2027-06-01T00:00:00Z") }, - ) - - val me = service.me("discount-founder") - assertTrue(me.betaFounder) - assertEquals("FREE", me.plan) - assertEquals(PlanSource.FREE, me.planSource) - assertTrue(store.grantsOf("discount-founder").isEmpty()) - } - - @Test - fun `a founder that subscribes to PERSONALIZED gets it and keeps the lifetime PRO`() { - val store = InMemoryEntitlementStore() - val now = Instant.parse("2028-06-01T00:00:00Z") - val service = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = PAST, founderUntil = FUTURE), - clock = { now }, - ) - val identity = service.identify("upgrading-founder") - store.upsertEntitlement( - StoredEntitlement( - userId = identity.id, - plan = "PERSONALIZED", - status = "ACTIVE", - source = "play", - expiresAt = Instant.parse("2028-07-01T00:00:00Z"), - startedAt = now, - autoRenew = true, - provider = "google_play", - providerTokenHash = "hash", - ), - ) - - val subscribed = service.me("upgrading-founder") - assertEquals("PERSONALIZED", subscribed.plan) - assertEquals(PlanSource.SUBSCRIPTION, subscribed.planSource) - assertEquals("ACTIVE", subscribed.status) - assertTrue(subscribed.betaFounder) - assertEquals(listOf("PRO"), subscribed.lifetimeGrants.map { it.plan }) - - // The subscription lapses: back to the lifetime PRO, never to FREE. - val lapsed = EntitlementService( - store, - config(mode = ProductMode.FREE, endDate = PAST, founderUntil = FUTURE), - clock = { Instant.parse("2028-08-01T00:00:00Z") }, - ).me("upgrading-founder") - assertEquals("PRO", lapsed.plan) - assertEquals(PlanSource.LIFETIME, lapsed.planSource) - assertEquals("NONE", lapsed.status) - assertTrue(lapsed.betaFounder) - } - - @Test - fun `transition after the beta - new FREE, founder PRO for life, subscribers keep their plan`() { - val store = InMemoryEntitlementStore() - store.upsertUser("beta-user", Instant.parse("2027-03-01T00:00:00Z")) - val after = EntitlementService( - store, - config( - mode = ProductMode.FREE, - endDate = Instant.parse("2027-12-31T23:59:59Z"), - founderUntil = Instant.parse("2027-12-31T23:59:59Z"), - ), - clock = { Instant.parse("2028-02-01T00:00:00Z") }, - ) - - val founder = after.me("beta-user") - assertTrue(founder.betaFounder) - assertEquals("PRO", founder.plan) - assertFalse(founder.betaAccess) - - val newcomer = after.me("new-user") - assertFalse(newcomer.betaFounder) - assertEquals("FREE", newcomer.plan) - assertEquals(ProductConfig.DEFAULT_PLANS.getValue("FREE"), newcomer.entitlements) - } - - @Test - fun `me exposes the subscription behind a paid plan`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing(), - entitlementStore = store, - productConfig = config(endDate = PAST, founderUntil = PAST), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - - client.post("/v1/billing/verify") { - contentType(ContentType.Application.Json) - setBody(BillingVerifyRequest(productId, "purchase-token", packageName, deviceId = "sub-device")) - } - val me = json(client.get("/v1/me") { header("X-Device-Id", "sub-device") }.bodyAsText()) - assertEquals("PRO", me.text("plan")) - assertEquals("ACTIVE", me.text("status")) - assertFalse(me.bool("betaAccess")) - val subscription = me.getValue("subscription").jsonObject - assertEquals("google_play", subscription.text("provider")) - assertEquals("play", subscription.text("source")) - assertTrue(subscription.bool("autoRenew")) - assertEquals("ADVANCED", me.getValue("entitlements").jsonObject.text("analytics")) - assertTrue(store.entitlementOf("sub-device")!!.autoRenew) - } - - @Test - fun `a cancelled subscription stops auto-renewing but stays entitled until expiry`() = testApplication { - val store = InMemoryEntitlementStore() - application { - module( - emptyAssets(), - adminToken = null, - analytics = null, - billing = billing( - rtdnSecret = "rtdn-secret", - state = "SUBSCRIPTION_STATE_CANCELED", - autoRenewing = false, - ), - entitlementStore = store, - productConfig = config(endDate = PAST, founderUntil = PAST), - ) - } - val client = createClient { install(ContentNegotiation) { json() } } - // Joined after the (closed) founder window: a plain subscriber. - val identity = store.upsertUser("cancel-device", Instant.parse("2025-01-01T00:00:00Z")).identity - store.upsertEntitlement( - StoredEntitlement( - userId = identity.id, - plan = "PRO", - status = "ACTIVE", - source = "play", - expiresAt = Instant.ofEpochMilli(future), - startedAt = PAST, - autoRenew = true, - provider = "google_play", - providerTokenHash = canonicalHash("purchase-token"), - ), - ) - - val webhook = client.post("/v1/billing/rtdn") { - contentType(ContentType.Application.Json) - header(HttpHeaders.Authorization, "Bearer rtdn-secret") - setBody(notification(NotificationType.CANCELED)) - } - assertEquals(HttpStatusCode.OK, webhook.status) - - val row = store.entitlementOf("cancel-device")!! - assertEquals("CANCELED", row.status) - assertFalse(row.autoRenew) - val me = json(client.get("/v1/me") { header("X-Device-Id", "cancel-device") }.bodyAsText()) - assertEquals("PRO", me.text("plan")) - assertEquals("CANCELED", me.text("status")) - assertFalse(me.getValue("subscription").jsonObject.bool("autoRenew")) - } } diff --git a/server/src/test/kotlin/com/glosso/server/billing/GooglePlayClientTest.kt b/server/src/test/kotlin/com/glosso/server/billing/GooglePlayClientTest.kt deleted file mode 100644 index 809eaf6..0000000 --- a/server/src/test/kotlin/com/glosso/server/billing/GooglePlayClientTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.glosso.server.billing - -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import java.net.InetSocketAddress -import java.security.KeyPairGenerator -import java.time.Instant -import java.util.Base64 -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.runBlocking -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * Exercises the real Play client and the service-account token flow against a - * local HTTP stub, so RS256 signing, token caching, URL building and JSON mapping - * are all covered without ever touching Google. - */ -class GooglePlayClientTest { - - private lateinit var server: HttpServer - private var port = 0 - private val tokenRequests = AtomicInteger(0) - private val playRequests = AtomicInteger(0) - - private val packageName = "me.shirobyte42.glosso" - private val purchaseToken = "abc.def-ghi" - private val futureInstant = Instant.parse("2030-01-01T00:00:00Z") - - @BeforeTest - fun start() { - server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - server.createContext("/token") { exchange -> - tokenRequests.incrementAndGet() - exchange.requestBody.readBytes() - val body = """{"access_token":"stub-access-token","expires_in":3600,"token_type":"Bearer"}""" - respond(exchange, 200, body) - } - server.createContext("/androidpublisher") { exchange -> - playRequests.incrementAndGet() - val key = exchange.requestURI.path - when { - key.endsWith("/tokens/missing") -> respond(exchange, 404, """{"error":{"code":404}}""") - key.contains("boom") -> respond(exchange, 500, """{"error":{"code":500}}""") - key.startsWith("/androidpublisher/v3/applications/$packageName/purchases/subscriptionsv2/tokens/") -> - respond(exchange, 200, subscriptionJson()) - else -> respond(exchange, 500, """{"error":{"code":500}}""") - } - } - server.start() - port = server.address.port - } - - @AfterTest - fun stop() { - server.stop(0) - } - - private fun respond(exchange: HttpExchange, status: Int, body: String) { - val bytes = body.toByteArray() - exchange.responseHeaders.add("Content-Type", "application/json") - exchange.sendResponseHeaders(status, bytes.size.toLong()) - exchange.responseBody.use { it.write(bytes) } - } - - private fun subscriptionJson(): String = """ - { - "kind": "androidpublisher#subscriptionPurchaseV2", - "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE", - "acknowledgementState": "ACKNOWLEDGED", - "lineItems": [ - { - "productId": "com.glosso.app.pro", - "expiryTime": "2030-01-01T00:00:00Z", - "autoRenewingPlan": {"autoRenewEnabled": true}, - "offerDetails": {"basePlanId": "monthly", "offerId": "free-month"} - } - ] - } - """.trimIndent() - - private fun serviceAccountJson(): String { - val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() - val pem = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(pair.private.encoded) - val escaped = pem.replace("\n", "\\n") - return """ - { - "type": "service_account", - "client_email": "glosso-billing@example.iam.gserviceaccount.com", - "private_key": "$escaped", - "token_uri": "http://127.0.0.1:$port/token" - } - """.trimIndent() - } - - private fun client(): GooglePlaySubscriptionsClient { - val key = ServiceAccountKey.fromJson(serviceAccountJson()) - val provider = ServiceAccountTokenProvider(key) - return GooglePlaySubscriptionsClient(provider, baseUrl = "http://127.0.0.1:$port") - } - - @Test - fun `maps a Play subscription and reuses the access token`(): Unit = runBlocking { - val client = client() - - val first = client.getSubscription(packageName, purchaseToken) - val second = client.getSubscription(packageName, purchaseToken) - - assertEquals("SUBSCRIPTION_STATE_ACTIVE", first?.state) - assertEquals("com.glosso.app.pro", first?.lineItems?.single()?.productId) - assertEquals(futureInstant.toEpochMilli(), first?.lineItems?.single()?.expiryTimeMillis) - assertTrue(first?.lineItems?.single()?.autoRenewing == true) - assertEquals("free-month", first?.lineItems?.single()?.offerId) - assertEquals(first, second) - assertEquals(2, playRequests.get()) - assertEquals(1, tokenRequests.get(), "the access token must be reused") - } - - @Test - fun `an unknown token comes back as null, not an error`(): Unit = runBlocking { - val client = client() - - val result = client.getSubscription(packageName, "missing") - - assertNull(result) - } - - @Test - fun `a Play outage surfaces as an exception so the caller can fail closed`(): Unit = runBlocking { - val client = client() - - assertFailsWith { - client.getSubscription(packageName, "boom") - } - } - - @Test - fun `service account JSON must carry a client email and key`() { - assertFailsWith { - ServiceAccountKey.fromJson("""{"client_email":"","private_key":""}""") - } - } -} diff --git a/server/src/test/kotlin/com/glosso/server/billing/SubscriptionCacheTest.kt b/server/src/test/kotlin/com/glosso/server/billing/SubscriptionCacheTest.kt deleted file mode 100644 index 6a7427a..0000000 --- a/server/src/test/kotlin/com/glosso/server/billing/SubscriptionCacheTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.glosso.server.billing - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class SubscriptionCacheTest { - - private var now = 1_000_000L - private val cache = SubscriptionCache(ttlMillis = 1_000, clock = { now }) - - private val entitled = Entitlement(EntitlementStatus.ACTIVE, true, expiresAtMillis = 9_999_999_999_999L) - private val expired = Entitlement(EntitlementStatus.NONE, false, expiresAtMillis = null) - - @Test - fun `a stored verdict is returned before the ttl`() { - cache.put("token", entitled) - assertEquals(entitled, cache.get("token")) - } - - @Test - fun `a verdict is gone once the ttl passes`() { - cache.put("token", entitled) - now += 1_001 - assertNull(cache.get("token")) - } - - @Test - fun `invalidate drops the entry immediately`() { - cache.put("token", entitled) - cache.invalidate("token") - assertNull(cache.get("token")) - } - - @Test - fun `different tokens do not collide`() { - cache.put("a", entitled) - cache.put("b", expired) - assertEquals(entitled, cache.get("a")) - assertEquals(expired, cache.get("b")) - } - - @Test - fun `an entitlement is not cached past its own expiry`() { - val soon = Entitlement(EntitlementStatus.ACTIVE, true, expiresAtMillis = now + 100) - cache.put("token", soon) - assertEquals(soon, cache.get("token")) - now += 101 - assertNull(cache.get("token")) - } - - @Test - fun `unentitled verdicts are cached too so forged tokens cannot hammer Play`() { - cache.put("token", expired) - assertEquals(expired, cache.get("token")) - } - - @Test - fun `a zero-length remaining entitlement is not cached`() { - val alreadyGone = Entitlement(EntitlementStatus.ACTIVE, true, expiresAtMillis = now) - cache.put("token", alreadyGone) - assertNull(cache.get("token")) - } -} diff --git a/server/src/test/kotlin/com/glosso/server/billing/SubscriptionVerifierTest.kt b/server/src/test/kotlin/com/glosso/server/billing/SubscriptionVerifierTest.kt deleted file mode 100644 index 8149fc2..0000000 --- a/server/src/test/kotlin/com/glosso/server/billing/SubscriptionVerifierTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.glosso.server.billing - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SubscriptionVerifierTest { - - private val product = "com.glosso.app.pro" - private val now = 1_000_000_000_000L - - private fun purchase( - state: String, - expiry: Long? = null, - offerId: String? = null, - productId: String = product, - ) = PlaySubscription( - state = state, - lineItems = listOf( - PlayLineItem(productId = productId, expiryTimeMillis = expiry, autoRenewing = true, offerId = offerId), - ), - cancelTimeMillis = null, - acknowledgeState = "ACKNOWLEDGED", - ) - - private fun verify(purchase: PlaySubscription?, trialOfferId: String? = null) = - SubscriptionVerifier.verify(purchase, product, trialOfferId, now) - - @Test - fun `an unknown token is not entitled`() { - val entitlement = verify(null) - assertEquals(EntitlementStatus.NONE, entitlement.status) - assertFalse(entitlement.entitled) - } - - @Test - fun `active subscription is entitled and carries the expiry`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_ACTIVE", expiry = now + 5_000)) - assertEquals(EntitlementStatus.ACTIVE, entitlement.status) - assertTrue(entitlement.entitled) - assertEquals(now + 5_000, entitlement.expiresAtMillis) - } - - @Test - fun `active subscription on the configured trial offer is a trial`() { - val entitlement = verify( - purchase("SUBSCRIPTION_STATE_ACTIVE", expiry = now + 5_000, offerId = "free-month"), - trialOfferId = "free-month", - ) - assertEquals(EntitlementStatus.TRIAL, entitlement.status) - assertTrue(entitlement.entitled) - } - - @Test - fun `a different offer is not reported as trial`() { - val entitlement = verify( - purchase("SUBSCRIPTION_STATE_ACTIVE", expiry = now + 5_000, offerId = "other"), - trialOfferId = "free-month", - ) - assertEquals(EntitlementStatus.ACTIVE, entitlement.status) - } - - @Test - fun `grace period keeps entitlement`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_IN_GRACE_PERIOD", expiry = now + 5_000)) - assertEquals(EntitlementStatus.GRACE, entitlement.status) - assertTrue(entitlement.entitled) - } - - @Test - fun `canceled but not expired keeps entitlement until expiry`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_CANCELED", expiry = now + 5_000)) - assertEquals(EntitlementStatus.CANCELED, entitlement.status) - assertTrue(entitlement.entitled) - } - - @Test - fun `canceled and past expiry is not entitled`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_CANCELED", expiry = now - 1)) - assertEquals(EntitlementStatus.CANCELED, entitlement.status) - assertFalse(entitlement.entitled) - } - - @Test - fun `expired is not entitled`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_EXPIRED", expiry = now - 1)) - assertEquals(EntitlementStatus.NONE, entitlement.status) - assertFalse(entitlement.entitled) - } - - @Test - fun `on hold pending and paused grant nothing`() { - for (state in listOf( - "SUBSCRIPTION_STATE_ON_HOLD", - "SUBSCRIPTION_STATE_PENDING", - "SUBSCRIPTION_STATE_PAUSED", - )) { - val entitlement = verify(purchase(state)) - assertEquals(EntitlementStatus.PENDING, entitlement.status, state) - assertFalse(entitlement.entitled, state) - } - } - - @Test - fun `an unknown Play state is not entitled`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_SOMETHING_NEW")) - assertEquals(EntitlementStatus.NONE, entitlement.status) - assertFalse(entitlement.entitled) - } - - @Test - fun `a purchase for another product is not entitled`() { - val entitlement = verify(purchase("SUBSCRIPTION_STATE_ACTIVE", expiry = now + 1, productId = "other.product")) - assertEquals(EntitlementStatus.NONE, entitlement.status) - assertFalse(entitlement.entitled) - } -} diff --git a/server/src/test/kotlin/com/glosso/server/product/EntitlementServiceGoogleTest.kt b/server/src/test/kotlin/com/glosso/server/product/AccountServiceGoogleTest.kt similarity index 66% rename from server/src/test/kotlin/com/glosso/server/product/EntitlementServiceGoogleTest.kt rename to server/src/test/kotlin/com/glosso/server/product/AccountServiceGoogleTest.kt index 0f1af01..443f47e 100644 --- a/server/src/test/kotlin/com/glosso/server/product/EntitlementServiceGoogleTest.kt +++ b/server/src/test/kotlin/com/glosso/server/product/AccountServiceGoogleTest.kt @@ -11,22 +11,15 @@ import kotlin.test.assertTrue * The Sign in with Google decision logic without the HTTP layer: linking, * moving the account to a new phone and refusing a token minted elsewhere. */ -class EntitlementServiceGoogleTest { +class AccountServiceGoogleTest { private val now: Instant = Instant.parse("2026-03-01T00:00:00Z") private fun service( - store: EntitlementStore, + store: AccountStore, clock: () -> Instant = { now }, - ): EntitlementService = EntitlementService( + ): AccountService = AccountService( store = store, - config = ProductConfig.fromEnv( - mapOf( - "GLOSSO_BETA_FOUNDER_BENEFIT_KIND" to "PRO_LIFETIME", - "GLOSSO_BETA_FOUNDER_UNTIL" to "2027-12-31T23:59:59Z", - "GLOSSO_BETA_END_DATE" to "2027-12-31T23:59:59Z", - ), - ), clock = clock, accountSecret = AccountSecret(ByteArray(32) { 3 }), googleVerifier = verifier, @@ -37,53 +30,47 @@ class EntitlementServiceGoogleTest { if (token == "bad") null else GoogleIdentity(token.substringBefore('|'), token.substringAfter('|', "").ifEmpty { null }) } - private fun EntitlementService.googleToken(deviceId: String, subject: String): String = + private fun AccountService.googleToken(deviceId: String, subject: String): String = "$subject|${googleSignInNonce(deviceId)}" @Test fun `sign-in is off without the server secret`() { - val service = EntitlementService(InMemoryEntitlementStore(), ProductConfig.fromEnv(emptyMap())) + val service = AccountService(InMemoryAccountStore()) assertEquals(false, service.googleSignInEnabled) } @Test fun `the first Google sign-in links the account where it is`() { - val store = InMemoryEntitlementStore() + val store = InMemoryAccountStore() val service = service(store) val result = service.signInWithGoogle("device-a", service.googleToken("device-a", "sub-1")) assertTrue(result is GoogleSignInResult.SignedIn, result.toString()) assertEquals(false, result.moved) - assertTrue(result.me.betaFounder) + assertEquals(listOf("google"), result.me.linkedAccounts) assertNotNull(store.identityOf("device-a")) } @Test - fun `signing in with Google on a new phone brings the founder account along`() { - val store = InMemoryEntitlementStore() - var clock = now - val service = service(store, clock = { clock }) + fun `signing in with Google on a new phone brings the account along`() { + val store = InMemoryAccountStore() + val service = service(store) service.signInWithGoogle("old-phone", service.googleToken("old-phone", "sub-1")) + val account = service.identify("old-phone") - // After the founder window, a new phone alone would not be a founder. - clock = Instant.parse("2028-06-01T00:00:00Z") - assertEquals(false, service.identify("new-phone").betaFounder) val result = service.signInWithGoogle("new-phone", service.googleToken("new-phone", "sub-1")) assertTrue(result is GoogleSignInResult.SignedIn, result.toString()) assertTrue(result.moved) - assertTrue(result.me.betaFounder) - assertEquals("PRO", result.me.plan) - assertEquals(PlanSource.LIFETIME, result.me.planSource) - assertTrue(store.grantsOf("new-phone").any { it.source == EntitlementService.BETA_FOUNDER_SOURCE }) + assertEquals(account.id, store.identityOf("new-phone")?.id) assertNull(store.identityOf("old-phone")) } @Test fun `a Google token without this device's nonce is refused`() { - val service = service(InMemoryEntitlementStore()) + val service = service(InMemoryAccountStore()) val stolen = service.googleToken("other-device", "sub-1") assertEquals(GoogleSignInResult.InvalidToken, service.signInWithGoogle("device-a", stolen)) @@ -93,7 +80,7 @@ class EntitlementServiceGoogleTest { @Test fun `a device linked to one Google account cannot take another one`() { - val service = service(InMemoryEntitlementStore()) + val service = service(InMemoryAccountStore()) service.signInWithGoogle("device-a", service.googleToken("device-a", "sub-1")) service.signInWithGoogle("device-b", service.googleToken("device-b", "sub-2")) diff --git a/server/src/test/kotlin/com/glosso/server/product/InMemoryAccountStore.kt b/server/src/test/kotlin/com/glosso/server/product/InMemoryAccountStore.kt new file mode 100644 index 0000000..371b28b --- /dev/null +++ b/server/src/test/kotlin/com/glosso/server/product/InMemoryAccountStore.kt @@ -0,0 +1,64 @@ +package com.glosso.server.product + +import java.time.Instant +import java.util.UUID + +/** + * In-memory [AccountStore] for route tests: the real one is exercised only + * against a live PostgreSQL, which the unit suite does not have. + */ +class InMemoryAccountStore : AccountStore { + + private val users = LinkedHashMap() + private val links = LinkedHashMap, UUID>() + + override fun upsertUser(deviceId: String, now: Instant): UserUpsert { + users[deviceId]?.let { return UserUpsert(it, created = false) } + val identity = UserIdentity(id = UUID.randomUUID(), deviceId = deviceId, createdAt = now) + users[deviceId] = identity + return UserUpsert(identity, created = true) + } + + override fun signInWithExternalAccount( + provider: String, + subject: String, + deviceId: String, + now: Instant, + ): ExternalSignIn { + val deviceUser = users[deviceId] ?: throw AccountChangedConcurrently("no user for the signing-in device") + val linked = links[provider to subject] + if (linked == deviceUser.id) return ExternalSignIn.SignedIn(deviceUser, moved = false) + if (links.any { (key, userId) -> key.first == provider && userId == deviceUser.id }) { + return ExternalSignIn.Conflict + } + if (linked == null) { + links[provider to subject] = deviceUser.id + return ExternalSignIn.SignedIn(deviceUser, moved = false) + } + val moved = checkNotNull(moveAccount(linked, deviceId)) + return ExternalSignIn.SignedIn(moved, moved = true) + } + + override fun linkedProviders(userId: UUID): List = + links.filterValues { it == userId }.keys.map { it.first }.sorted() + + /** Same merge rules as the JDBC store, for the state this fake keeps. */ + private fun moveAccount(accountUserId: UUID, deviceId: String): UserIdentity? { + val entry = users.entries.firstOrNull { it.value.id == accountUserId } ?: return null + val placeholder = users[deviceId] + if (placeholder != null && placeholder.id != accountUserId) { + links.replaceAll { key, userId -> + val held = links.any { (other, owner) -> owner == accountUserId && other.first == key.first } + if (userId == placeholder.id && !held) accountUserId else userId + } + links.values.removeIf { it == placeholder.id } + users.remove(deviceId) + } + users.remove(entry.key) + val moved = entry.value.copy(deviceId = deviceId) + users[deviceId] = moved + return moved + } + + fun identityOf(deviceId: String): UserIdentity? = users[deviceId] +} diff --git a/server/src/test/kotlin/com/glosso/server/product/InMemoryEntitlementStore.kt b/server/src/test/kotlin/com/glosso/server/product/InMemoryEntitlementStore.kt deleted file mode 100644 index 541a3bc..0000000 --- a/server/src/test/kotlin/com/glosso/server/product/InMemoryEntitlementStore.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.glosso.server.product - -import java.time.Instant -import java.util.UUID - -/** - * In-memory [EntitlementStore] for route tests: the real one is exercised only - * against a live PostgreSQL, which the unit suite does not have. - */ -class InMemoryEntitlementStore : EntitlementStore { - - private val users = LinkedHashMap() - private val entitlements = LinkedHashMap() - private val grants = mutableListOf() - private val links = LinkedHashMap, UUID>() - - override fun upsertUser(deviceId: String, now: Instant): UserUpsert { - users[deviceId]?.let { return UserUpsert(it, created = false) } - val identity = UserIdentity( - id = UUID.randomUUID(), - deviceId = deviceId, - createdAt = now, - betaFounder = false, - betaFounderSince = null, - betaFounderUntil = null, - betaFounderBenefit = null, - ) - users[deviceId] = identity - return UserUpsert(identity, created = true) - } - - override fun markBetaFounder(userId: UUID, since: Instant, until: Instant?, benefit: String?) { - val entry = users.entries.firstOrNull { it.value.id == userId } ?: return - val current = entry.value - if (current.betaFounder) return - users[entry.key] = current.copy( - betaFounder = true, - betaFounderSince = since, - betaFounderUntil = until, - betaFounderBenefit = benefit, - ) - } - - override fun entitlement(userId: UUID): StoredEntitlement? = entitlements[userId] - - override fun updateEntitlementsByProviderTokenHash( - providerTokenHash: String, - entitled: Boolean, - status: String, - expiresAt: Instant?, - autoRenew: Boolean, - ) { - entitlements.values - .filter { it.providerTokenHash == providerTokenHash && it.plan != "PERSONALIZED" } - .forEach { existing -> - entitlements[existing.userId] = existing.copy( - plan = if (entitled) existing.providerPlan() else "FREE", - status = status, - expiresAt = expiresAt, - autoRenew = autoRenew, - provider = existing.provider ?: "google_play", - ) - } - } - - override fun upsertEntitlement(entitlement: StoredEntitlement) { - entitlements[entitlement.userId] = entitlement - } - - override fun lifetimeGrants(userId: UUID): List = grants.filter { it.userId == userId } - - override fun grantLifetime( - userId: UUID, - plan: String, - source: String, - grantedAt: Instant, - provider: String?, - providerRefHash: String?, - ) { - if (grants.any { it.userId == userId && it.plan == plan && it.source == source }) return - grants += LifetimeGrant(userId, plan, source, provider, grantedAt) - } - - fun grantsOf(deviceId: String): List = - users[deviceId]?.let { lifetimeGrants(it.id) } ?: emptyList() - - override fun signInWithExternalAccount( - provider: String, - subject: String, - deviceId: String, - now: Instant, - ): ExternalSignIn { - val deviceUser = users[deviceId] ?: throw AccountChangedConcurrently("no user for the signing-in device") - val linked = links[provider to subject] - if (linked == deviceUser.id) return ExternalSignIn.SignedIn(deviceUser, moved = false) - if (links.any { (key, userId) -> key.first == provider && userId == deviceUser.id }) { - return ExternalSignIn.Conflict - } - if (linked == null) { - links[provider to subject] = deviceUser.id - return ExternalSignIn.SignedIn(deviceUser, moved = false) - } - val moved = checkNotNull(moveAccount(linked, deviceId, now)) - return ExternalSignIn.SignedIn(moved, moved = true) - } - - override fun linkedProviders(userId: UUID): List = - links.filterValues { it == userId }.keys.map { it.first }.sorted() - - /** Same merge rules as the JDBC store, for the state this fake keeps. */ - private fun moveAccount(accountUserId: UUID, deviceId: String, now: Instant): UserIdentity? { - val entry = users.entries.firstOrNull { it.value.id == accountUserId } ?: return null - var account = entry.value - val placeholder = users[deviceId] - if (placeholder != null && placeholder.id != accountUserId) { - grants.replaceAll { grant -> - val held = grants.any { it.userId == accountUserId && it.plan == grant.plan && it.source == grant.source } - if (grant.userId == placeholder.id && !held) grant.copy(userId = accountUserId) else grant - } - grants.removeIf { it.userId == placeholder.id } - val placeholderEntitlement = entitlements.remove(placeholder.id) - val accountEntitlement = entitlements[accountUserId] - if (placeholderEntitlement != null && - (accountEntitlement == null || entitlementMergeOrder(now).compare(accountEntitlement, placeholderEntitlement) < 0) - ) { - entitlements[accountUserId] = placeholderEntitlement.copy(userId = accountUserId) - } - links.replaceAll { key, userId -> - val held = links.any { (other, owner) -> owner == accountUserId && other.first == key.first } - if (userId == placeholder.id && !held) accountUserId else userId - } - links.values.removeIf { it == placeholder.id } - if (!account.betaFounder && placeholder.betaFounder) { - account = account.copy( - betaFounder = true, - betaFounderSince = placeholder.betaFounderSince, - betaFounderUntil = placeholder.betaFounderUntil, - betaFounderBenefit = placeholder.betaFounderBenefit, - ) - } - users.remove(deviceId) - } - users.remove(entry.key) - val moved = account.copy(deviceId = deviceId) - users[deviceId] = moved - return moved - } - - fun identityOf(deviceId: String): UserIdentity? = users[deviceId] - - fun entitlementOf(deviceId: String): StoredEntitlement? = - users[deviceId]?.let { entitlements[it.id] } -} diff --git a/server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStoreLockingPostgresTest.kt b/server/src/test/kotlin/com/glosso/server/product/JdbcAccountStoreLockingPostgresTest.kt similarity index 96% rename from server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStoreLockingPostgresTest.kt rename to server/src/test/kotlin/com/glosso/server/product/JdbcAccountStoreLockingPostgresTest.kt index 6e627a7..dfa4509 100644 --- a/server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStoreLockingPostgresTest.kt +++ b/server/src/test/kotlin/com/glosso/server/product/JdbcAccountStoreLockingPostgresTest.kt @@ -24,11 +24,11 @@ import kotlin.test.assertFailsWith * external transaction holds the `users` row while the flows queue up behind * it) and check that each one now finishes with a clean outcome. */ -class JdbcEntitlementStoreLockingPostgresTest { +class JdbcAccountStoreLockingPostgresTest { private lateinit var postgres: EmbeddedPostgres private lateinit var dataSource: DataSource - private lateinit var store: JdbcEntitlementStore + private lateinit var store: JdbcAccountStore private lateinit var pool: ExecutorService private val now = Instant.parse("2026-06-01T00:00:00Z") @@ -38,7 +38,7 @@ class JdbcEntitlementStoreLockingPostgresTest { postgres = EmbeddedPostgres.builder().start() dataSource = postgres.postgresDatabase SchemaMigrator.migrate(dataSource) - store = JdbcEntitlementStore(dataSource) + store = JdbcAccountStore(dataSource) pool = Executors.newCachedThreadPool() } diff --git a/server/src/test/kotlin/com/glosso/server/product/JdbcAccountStorePostgresTest.kt b/server/src/test/kotlin/com/glosso/server/product/JdbcAccountStorePostgresTest.kt new file mode 100644 index 0000000..a99ed46 --- /dev/null +++ b/server/src/test/kotlin/com/glosso/server/product/JdbcAccountStorePostgresTest.kt @@ -0,0 +1,215 @@ +package com.glosso.server.product + +import com.glosso.server.generation.SchemaMigrator +import io.zonky.test.db.postgres.embedded.EmbeddedPostgres +import java.time.Instant +import java.util.UUID +import javax.sql.DataSource +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Exercises the real JDBC store against a real PostgreSQL, because the in-memory + * fake cannot prove the SQL of the account move. The route test locks the flow; + * this locks the statements. + */ +class JdbcAccountStorePostgresTest { + + private lateinit var postgres: EmbeddedPostgres + private lateinit var dataSource: DataSource + private lateinit var store: JdbcAccountStore + + @BeforeTest + fun startPostgres() { + postgres = EmbeddedPostgres.builder().start() + dataSource = postgres.postgresDatabase + SchemaMigrator.migrate(dataSource) + store = JdbcAccountStore(dataSource) + } + + @AfterTest + fun stopPostgres() { + store.close() + postgres.close() + } + + private fun user(deviceId: String): UserIdentity = + store.upsertUser(deviceId, Instant.parse("2026-03-01T00:00:00Z")).identity + + private fun updatedAt(userId: UUID): Instant = + dataSource.connection.use { connection -> + connection.prepareStatement("select updated_at from glosso.users where id = ?").use { statement -> + statement.setObject(1, userId) + statement.executeQuery().use { rs -> + check(rs.next()) { "users row missing for $userId" } + rs.getTimestamp("updated_at").toInstant() + } + } + } + + @Test + fun `reading an existing user does not touch updated_at`() { + val created = Instant.parse("2026-03-01T00:00:00Z") + val identity = store.upsertUser("hot-device", created).identity + val before = updatedAt(identity.id) + + // The hot GET /v1/me path calls upsertUser again with a later instant; + // it must be a pure read, so updated_at stays put. + store.upsertUser("hot-device", created.plusSeconds(3_600)) + + assertEquals(before, updatedAt(identity.id)) + } + + private fun execute(sql: String, vararg params: Any?) { + dataSource.connection.use { connection -> + connection.prepareStatement(sql).use { statement -> + params.forEachIndexed { i, value -> statement.setObject(i + 1, value) } + statement.executeUpdate() + } + } + } + + private fun queryStrings(sql: String, vararg params: Any?): List = + dataSource.connection.use { connection -> + connection.prepareStatement(sql).use { statement -> + params.forEachIndexed { i, value -> statement.setObject(i + 1, value) } + statement.executeQuery().use { rs -> + buildList { while (rs.next()) add(rs.getString(1)) } + } + } + } + + /** Rows written by the removed paid tier, which a move must still carry over. */ + private fun legacyPaidTierRows(userId: UUID, tokenHash: String) { + execute( + "update glosso.users set beta_founder = true, beta_founder_since = created_at where id = ?", + userId, + ) + execute( + "insert into glosso.lifetime_grants (user_id, plan, source) values (?, 'PRO', 'beta_founder')", + userId, + ) + execute( + "insert into glosso.entitlements (user_id, plan, status, source, provider_token_hash) " + + "values (?, 'PRO', 'ACTIVE', 'play', ?)", + userId, tokenHash, + ) + } + + @Test + fun `signing in merges what the new phone had instead of deleting it`() { + val now = Instant.parse("2026-06-01T00:00:00Z") + val account = user("old-phone") + val fresh = user("new-phone") + + // English: the account's profile is older than the new phone's. + execute( + "insert into glosso.learner_profiles (user_id, language, cefr, updated_at) values (?, 'en', 'A2', ?)", + account.id, java.sql.Timestamp.from(now.minusSeconds(86_400)), + ) + execute( + "insert into glosso.learner_profiles (user_id, language, cefr, updated_at) values (?, 'en', 'B1', ?)", + fresh.id, java.sql.Timestamp.from(now.minusSeconds(60)), + ) + // French only on the account; German only on the new phone. + execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'fr', 'A1')", account.id) + execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'de', 'A1')", fresh.id) + // Same phoneme on both sides, the account's being the newest. + execute( + "insert into glosso.phoneme_profiles (user_id, language, phoneme, score, updated_at) values (?, 'en', 'θ', 80, ?)", + account.id, java.sql.Timestamp.from(now), + ) + execute( + "insert into glosso.phoneme_profiles (user_id, language, phoneme, score, updated_at) values (?, 'en', 'θ', 20, ?)", + fresh.id, java.sql.Timestamp.from(now.minusSeconds(3_600)), + ) + execute("insert into glosso.phoneme_profiles (user_id, language, phoneme, score) values (?, 'en', 'ð', 55)", fresh.id) + execute("insert into glosso.user_needs (user_id, language, skill) values (?, 'de', 'pronunciation')", fresh.id) + legacyPaidTierRows(fresh.id, "fresh-token") + + store.signInWithExternalAccount("google", "sub-merge", "old-phone", now) + val moved = store.signInWithExternalAccount("google", "sub-merge", "new-phone", now) + + assertEquals(account.id, (moved as ExternalSignIn.SignedIn).identity.id) + assertEquals(account.id, store.upsertUser("new-phone", now).identity.id) + assertEquals( + listOf("de:A1", "en:B1", "fr:A1"), + queryStrings( + "select language || ':' || cefr from glosso.learner_profiles where user_id = ? order by language", + account.id, + ), + ) + assertEquals( + setOf("ð:55", "θ:80"), + queryStrings( + "select phoneme || ':' || score from glosso.phoneme_profiles where user_id = ?", + account.id, + ).toSet(), + ) + assertEquals(listOf("de"), queryStrings("select language from glosso.user_needs where user_id = ?", account.id)) + assertEquals( + listOf("fresh-token"), + queryStrings("select provider_token_hash from glosso.entitlements where user_id = ?", account.id), + ) + assertEquals( + listOf("beta_founder"), + queryStrings("select source from glosso.lifetime_grants where user_id = ?", account.id), + ) + assertEquals( + listOf("true"), + queryStrings("select beta_founder::text from glosso.users where id = ?", account.id), + ) + assertEquals( + emptyList(), + queryStrings("select id::text from glosso.users where id = ?", fresh.id), + ) + } + + @Test + fun `a Google account links once and then follows the person to a new phone`() { + val now = Instant.parse("2026-06-01T00:00:00Z") + val account = user("phone-1") + + val linked = store.signInWithExternalAccount("google", "sub-1", "phone-1", now) + assertEquals(ExternalSignIn.SignedIn(store.upsertUser("phone-1", now).identity, moved = false), linked) + // Idempotent on the same phone. + assertEquals(false, (store.signInWithExternalAccount("google", "sub-1", "phone-1", now) as ExternalSignIn.SignedIn).moved) + + val fresh = user("phone-2") + execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'de', 'A1')", fresh.id) + val moved = store.signInWithExternalAccount("google", "sub-1", "phone-2", now) + + assertTrue(moved is ExternalSignIn.SignedIn && moved.moved, moved.toString()) + assertEquals(account.id, moved.identity.id) + assertEquals("phone-2", moved.identity.deviceId) + assertEquals(listOf("de"), queryStrings("select language from glosso.learner_profiles where user_id = ?", account.id)) + assertEquals( + listOf(account.id.toString()), + queryStrings("select user_id::text from glosso.linked_accounts where provider = 'google' and subject = 'sub-1'"), + ) + // The old phone starts a new, unlinked anonymous user. + val orphan = store.upsertUser("phone-1", now).identity + assertTrue(orphan.id != account.id) + } + + @Test + fun `a phone linked to one Google account refuses another one`() { + val now = Instant.parse("2026-06-01T00:00:00Z") + user("phone-a") + user("phone-b") + store.signInWithExternalAccount("google", "sub-a", "phone-a", now) + store.signInWithExternalAccount("google", "sub-b", "phone-b", now) + + assertEquals(ExternalSignIn.Conflict, store.signInWithExternalAccount("google", "sub-b", "phone-a", now)) + assertEquals(ExternalSignIn.Conflict, store.signInWithExternalAccount("google", "sub-c", "phone-a", now)) + assertEquals( + listOf("sub-a"), + queryStrings( + "select subject from glosso.linked_accounts l join glosso.users u on u.id = l.user_id where u.device_id = 'phone-a'", + ), + ) + } +} diff --git a/server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStorePostgresTest.kt b/server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStorePostgresTest.kt deleted file mode 100644 index 5314556..0000000 --- a/server/src/test/kotlin/com/glosso/server/product/JdbcEntitlementStorePostgresTest.kt +++ /dev/null @@ -1,357 +0,0 @@ -package com.glosso.server.product - -import com.glosso.server.generation.SchemaMigrator -import io.zonky.test.db.postgres.embedded.EmbeddedPostgres -import java.time.Instant -import java.util.UUID -import javax.sql.DataSource -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * Exercises the real JDBC store against a real PostgreSQL, because the in-memory - * fake cannot prove the SQL that resolves the RTDN revocation. The route test - * locks the flow; this locks the statement. - */ -class JdbcEntitlementStorePostgresTest { - - private lateinit var postgres: EmbeddedPostgres - private lateinit var dataSource: DataSource - private lateinit var store: JdbcEntitlementStore - - @BeforeTest - fun startPostgres() { - postgres = EmbeddedPostgres.builder().start() - dataSource = postgres.postgresDatabase - SchemaMigrator.migrate(dataSource) - store = JdbcEntitlementStore(dataSource) - } - - @AfterTest - fun stopPostgres() { - store.close() - postgres.close() - } - - private fun user(deviceId: String): UserIdentity = - store.upsertUser(deviceId, Instant.parse("2026-03-01T00:00:00Z")).identity - - private fun updatedAt(userId: UUID): Instant = - dataSource.connection.use { connection -> - connection.prepareStatement("select updated_at from glosso.users where id = ?").use { statement -> - statement.setObject(1, userId) - statement.executeQuery().use { rs -> - check(rs.next()) { "users row missing for $userId" } - rs.getTimestamp("updated_at").toInstant() - } - } - } - - private fun paid(userId: UUID, tokenHash: String, plan: String = "PRO"): StoredEntitlement = - StoredEntitlement( - userId = userId, - plan = plan, - status = "ACTIVE", - source = "play", - expiresAt = Instant.parse("2026-04-01T00:00:00Z"), - startedAt = Instant.parse("2026-03-01T00:00:00Z"), - autoRenew = false, - provider = "google_play", - providerTokenHash = tokenHash, - ) - - @Test - fun `revoking a token revokes every device that verified it`() { - val hash = "canonical-hash" - val devices = listOf("device-a", "device-b").map(::user) - devices.forEach { store.upsertEntitlement(paid(it.id, hash)) } - - store.updateEntitlementsByProviderTokenHash( - providerTokenHash = hash, - entitled = false, - status = "NONE", - expiresAt = null, - autoRenew = false, - ) - - for (device in devices) { - val row = store.entitlement(device.id) - assertEquals("FREE", row?.plan, device.deviceId) - assertEquals("NONE", row?.status, device.deviceId) - // Kept so a later renewal or refund for the same token still lands. - assertEquals(hash, row?.providerTokenHash, device.deviceId) - } - } - - @Test - fun `a renewal re-entitles every device without downgrading personalized`() { - val hash = "renewal-hash" - val a = user("device-a") - val b = user("device-b") - val personalized = user("device-c") - store.upsertEntitlement(paid(a.id, hash)) - store.upsertEntitlement(paid(b.id, hash)) - store.upsertEntitlement(paid(personalized.id, hash, plan = "PERSONALIZED")) - - store.updateEntitlementsByProviderTokenHash( - providerTokenHash = hash, - entitled = true, - status = "ACTIVE", - expiresAt = Instant.parse("2026-05-01T00:00:00Z"), - autoRenew = true, - ) - - assertEquals("PRO", store.entitlement(a.id)?.plan) - assertEquals(true, store.entitlement(a.id)?.autoRenew) - assertEquals("PRO", store.entitlement(b.id)?.plan) - assertEquals("PERSONALIZED", store.entitlement(personalized.id)?.plan) - } - - @Test - fun `revoking a token leaves a personalized grant untouched`() { - val hash = "personalized-hash" - val pro = user("device-a") - val personalized = user("device-b") - store.upsertEntitlement(paid(pro.id, hash)) - store.upsertEntitlement(paid(personalized.id, hash, plan = "PERSONALIZED")) - val before = store.entitlement(personalized.id) - - store.updateEntitlementsByProviderTokenHash( - providerTokenHash = hash, - entitled = false, - status = "NONE", - expiresAt = null, - autoRenew = false, - ) - - assertEquals("FREE", store.entitlement(pro.id)?.plan) - val after = store.entitlement(personalized.id) - assertEquals("PERSONALIZED", after?.plan) - assertEquals("ACTIVE", after?.status) - assertEquals(before?.expiresAt, after?.expiresAt) - assertEquals(hash, after?.providerTokenHash) - } - - @Test - fun `reading an existing user does not touch updated_at`() { - val created = Instant.parse("2026-03-01T00:00:00Z") - val identity = store.upsertUser("hot-device", created).identity - val before = updatedAt(identity.id) - - // The hot GET /v1/me path calls upsertUser again with a later instant; - // it must be a pure read, so updated_at stays put. - store.upsertUser("hot-device", created.plusSeconds(3_600)) - - assertEquals(before, updatedAt(identity.id)) - } - - @Test - fun `a lifetime grant is recorded once and a fraud revocation is never undone`() { - val founder = user("founder-device") - val at = Instant.parse("2026-10-01T00:00:00Z") - store.grantLifetime(founder.id, "PRO", "beta_founder", at) - store.grantLifetime(founder.id, "PRO", "beta_founder", at.plusSeconds(60)) - - val grants = store.lifetimeGrants(founder.id) - assertEquals(1, grants.size) - assertEquals("PRO", grants.single().plan) - assertEquals(at, grants.single().grantedAt) - - dataSource.connection.use { connection -> - connection.createStatement().use { - it.executeUpdate( - "update glosso.lifetime_grants set revoked_at = now(), revoke_reason = 'fraud' " + - "where user_id = '${founder.id}'", - ) - } - } - assertEquals(emptyList(), store.lifetimeGrants(founder.id)) - store.grantLifetime(founder.id, "PRO", "beta_founder", at) - assertEquals(emptyList(), store.lifetimeGrants(founder.id)) - } - - @Test - fun `V5 backfills the lifetime PRO of founders marked before it`() { - val founder = user("legacy-founder") - store.markBetaFounder(founder.id, founder.createdAt, null, "Ventaja Beta Founder") - val standard = user("legacy-standard") - - val v5 = checkNotNull(javaClass.getResource("/db/migration/V5__lifetime_grants.sql")).readText() - dataSource.connection.use { connection -> connection.createStatement().use { it.execute(v5) } } - - assertEquals(listOf("PRO"), store.lifetimeGrants(founder.id).map { it.plan }) - assertEquals(emptyList(), store.lifetimeGrants(standard.id)) - val benefit = dataSource.connection.use { connection -> - connection.prepareStatement("select beta_founder_benefit from glosso.users where id = ?").use { st -> - st.setObject(1, founder.id) - st.executeQuery().use { rs -> rs.next(); rs.getString(1) } - } - } - assertEquals("PRO_LIFETIME", benefit) - } - - private fun execute(sql: String, vararg params: Any?) { - dataSource.connection.use { connection -> - connection.prepareStatement(sql).use { statement -> - params.forEachIndexed { i, value -> statement.setObject(i + 1, value) } - statement.executeUpdate() - } - } - } - - private fun queryStrings(sql: String, vararg params: Any?): List = - dataSource.connection.use { connection -> - connection.prepareStatement(sql).use { statement -> - params.forEachIndexed { i, value -> statement.setObject(i + 1, value) } - statement.executeQuery().use { rs -> - buildList { while (rs.next()) add(rs.getString(1)) } - } - } - } - - @Test - fun `signing in merges what the new phone had instead of deleting it`() { - val now = Instant.parse("2026-06-01T00:00:00Z") - val account = user("old-phone") - store.markBetaFounder(account.id, account.createdAt, null, "PRO_LIFETIME") - store.grantLifetime(account.id, "PRO", EntitlementService.BETA_FOUNDER_SOURCE, account.createdAt) - val fresh = user("new-phone") - - // English: the account's profile is older than the new phone's. - execute( - "insert into glosso.learner_profiles (user_id, language, cefr, updated_at) values (?, 'en', 'A2', ?)", - account.id, java.sql.Timestamp.from(now.minusSeconds(86_400)), - ) - execute( - "insert into glosso.learner_profiles (user_id, language, cefr, updated_at) values (?, 'en', 'B1', ?)", - fresh.id, java.sql.Timestamp.from(now.minusSeconds(60)), - ) - // French only on the account; German only on the new phone. - execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'fr', 'A1')", account.id) - execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'de', 'A1')", fresh.id) - // Same phoneme on both sides, the account's being the newest. - execute( - "insert into glosso.phoneme_profiles (user_id, language, phoneme, score, updated_at) values (?, 'en', 'θ', 80, ?)", - account.id, java.sql.Timestamp.from(now), - ) - execute( - "insert into glosso.phoneme_profiles (user_id, language, phoneme, score, updated_at) values (?, 'en', 'θ', 20, ?)", - fresh.id, java.sql.Timestamp.from(now.minusSeconds(3_600)), - ) - execute("insert into glosso.phoneme_profiles (user_id, language, phoneme, score) values (?, 'en', 'ð', 55)", fresh.id) - execute("insert into glosso.user_needs (user_id, language, skill) values (?, 'de', 'pronunciation')", fresh.id) - // The new phone bought PRO through Play; the account never paid. - store.upsertEntitlement(paid(fresh.id, "fresh-token")) - - store.signInWithExternalAccount("google", "sub-merge", "old-phone", now) - val moved = store.signInWithExternalAccount("google", "sub-merge", "new-phone", now) - - assertEquals(account.id, (moved as ExternalSignIn.SignedIn).identity.id) - assertEquals(account.id, store.upsertUser("new-phone", now).identity.id) - assertEquals( - listOf("de:A1", "en:B1", "fr:A1"), - queryStrings( - "select language || ':' || cefr from glosso.learner_profiles where user_id = ? order by language", - account.id, - ), - ) - assertEquals( - setOf("ð:55", "θ:80"), - queryStrings( - "select phoneme || ':' || score from glosso.phoneme_profiles where user_id = ?", - account.id, - ).toSet(), - ) - assertEquals(listOf("de"), queryStrings("select language from glosso.user_needs where user_id = ?", account.id)) - assertEquals("fresh-token", store.entitlement(account.id)?.providerTokenHash) - assertEquals(listOf("PRO"), store.lifetimeGrants(account.id).map { it.plan }) - assertEquals( - emptyList(), - queryStrings("select id::text from glosso.users where id = ?", fresh.id), - ) - } - - private fun mergeEntitlements(account: StoredEntitlement?, fresh: StoredEntitlement?): StoredEntitlement? { - val now = Instant.parse("2026-03-15T00:00:00Z") - val tag = UUID.randomUUID() - val accountUser = user("account-$tag") - val freshUser = user("fresh-$tag") - account?.let { store.upsertEntitlement(it.copy(userId = accountUser.id)) } - fresh?.let { store.upsertEntitlement(it.copy(userId = freshUser.id)) } - store.signInWithExternalAccount("google", "sub-$tag", "account-$tag", now) - store.signInWithExternalAccount("google", "sub-$tag", "fresh-$tag", now) - return store.entitlement(accountUser.id) - } - - @Test - fun `merging two paid users keeps the highest entitlement, whichever side has it`() { - val someone = UUID.randomUUID() - val pro = paid(someone, "pro-token", plan = "PRO") - val personalized = paid(someone, "personalized-token", plan = "PERSONALIZED") - val lapsed = paid(someone, "lapsed-token", plan = "PERSONALIZED") - .copy(expiresAt = Instant.parse("2026-03-10T00:00:00Z")) - val free = pro.copy(plan = "FREE", status = "NONE", providerTokenHash = null) - - // The new phone's higher plan used to vanish with the placeholder. - assertEquals("personalized-token", mergeEntitlements(pro, personalized)?.providerTokenHash) - assertEquals("personalized-token", mergeEntitlements(personalized, pro)?.providerTokenHash) - // In force beats a higher plan that already lapsed. - assertEquals("pro-token", mergeEntitlements(lapsed, pro)?.providerTokenHash) - assertEquals("pro-token", mergeEntitlements(free, pro)?.providerTokenHash) - // A tie keeps the account's own row. - assertEquals("pro-token", mergeEntitlements(pro, pro.copy(providerTokenHash = "other-pro"))?.providerTokenHash) - assertEquals("pro-token", mergeEntitlements(pro, null)?.providerTokenHash) - } - - @Test - fun `a Google account links once and then follows the person to a new phone`() { - val now = Instant.parse("2026-06-01T00:00:00Z") - val account = user("phone-1") - store.markBetaFounder(account.id, account.createdAt, null, "PRO_LIFETIME") - store.grantLifetime(account.id, "PRO", EntitlementService.BETA_FOUNDER_SOURCE, account.createdAt) - - val linked = store.signInWithExternalAccount("google", "sub-1", "phone-1", now) - assertEquals(ExternalSignIn.SignedIn(store.upsertUser("phone-1", now).identity, moved = false), linked) - // Idempotent on the same phone. - assertEquals(false, (store.signInWithExternalAccount("google", "sub-1", "phone-1", now) as ExternalSignIn.SignedIn).moved) - - val fresh = user("phone-2") - execute("insert into glosso.learner_profiles (user_id, language, cefr) values (?, 'de', 'A1')", fresh.id) - val moved = store.signInWithExternalAccount("google", "sub-1", "phone-2", now) - - assertTrue(moved is ExternalSignIn.SignedIn && moved.moved, moved.toString()) - assertEquals(account.id, moved.identity.id) - assertEquals("phone-2", moved.identity.deviceId) - assertTrue(moved.identity.betaFounder) - assertEquals(listOf("PRO"), store.lifetimeGrants(account.id).map { it.plan }) - assertEquals(listOf("de"), queryStrings("select language from glosso.learner_profiles where user_id = ?", account.id)) - assertEquals( - listOf(account.id.toString()), - queryStrings("select user_id::text from glosso.linked_accounts where provider = 'google' and subject = 'sub-1'"), - ) - // The old phone starts a new, unlinked anonymous user. - val orphan = store.upsertUser("phone-1", now).identity - assertTrue(orphan.id != account.id) - } - - @Test - fun `a phone linked to one Google account refuses another one`() { - val now = Instant.parse("2026-06-01T00:00:00Z") - user("phone-a") - user("phone-b") - store.signInWithExternalAccount("google", "sub-a", "phone-a", now) - store.signInWithExternalAccount("google", "sub-b", "phone-b", now) - - assertEquals(ExternalSignIn.Conflict, store.signInWithExternalAccount("google", "sub-b", "phone-a", now)) - assertEquals(ExternalSignIn.Conflict, store.signInWithExternalAccount("google", "sub-c", "phone-a", now)) - assertEquals( - listOf("sub-a"), - queryStrings( - "select subject from glosso.linked_accounts l join glosso.users u on u.id = l.user_id where u.device_id = 'phone-a'", - ), - ) - } -} diff --git a/server/src/test/kotlin/com/glosso/server/product/ProductConfigTest.kt b/server/src/test/kotlin/com/glosso/server/product/ProductConfigTest.kt index e4bbc8f..9542d92 100644 --- a/server/src/test/kotlin/com/glosso/server/product/ProductConfigTest.kt +++ b/server/src/test/kotlin/com/glosso/server/product/ProductConfigTest.kt @@ -1,138 +1,60 @@ package com.glosso.server.product import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.boolean import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlin.test.assertTrue /** - * The environment is the single authority for the frozen config names and their - * safe defaults. These lock the defaults, the overrides and the exact served - * shape (including the nulls that mean "not decided yet"). + * The environment is the single authority for the config names and their safe + * defaults. These lock the defaults, the overrides and the exact served shape. */ class ProductConfigTest { @Test - fun `defaults are the open beta with no priced benefit`() { + fun `defaults declare no flags and the default version policy`() { val config = ProductConfig.fromEnv(emptyMap()) - assertEquals(ProductMode.BETA, config.mode) - assertEquals(ProductConfig.DEFAULT_END_DATE, config.beta.endDate) - assertEquals(Instant.parse("2027-12-31T23:59:59Z"), config.beta.endDate) - assertEquals(Instant.parse("2027-12-31T23:59:59Z"), config.beta.founderUntil) - assertTrue(config.beta.founderBenefit.grantsProLifetime) - assertTrue(config.beta.founderEnabled) - assertEquals("PRO_LIFETIME", config.beta.founderBenefit.kind) - assertNull(config.beta.founderBenefit.value) - assertNull(config.beta.founderBenefit.until) - assertEquals("Glosso Pro gratis para siempre", config.beta.founderBenefit.label) assertEquals(2210, config.client.minimumSupportedVersion) assertEquals(2219, config.client.recommendedVersion) assertEquals(false, config.client.forceUpdate) - assertEquals(true, config.flagsAt(Instant.parse("2026-10-01T00:00:00Z"))["beta_banner"]) - assertEquals(false, config.flags["pro"]) - assertEquals(ProductConfig.DEFAULT_PLANS, config.plans) - assertNull(config.limits) - assertNull(config.pricing) + assertTrue(config.flags.isEmpty()) } @Test - fun `the environment overrides every frozen name`() { + fun `the environment overrides every name`() { val config = ProductConfig.fromEnv( mapOf( - "GLOSSO_PRODUCT_MODE" to "FREE", - "GLOSSO_BETA_END_DATE" to "2025-01-02T03:04:05Z", - "GLOSSO_BETA_FOUNDER_ENABLED" to "false", - "GLOSSO_BETA_FOUNDER_BENEFIT_KIND" to "lifetime_percent", - "GLOSSO_BETA_FOUNDER_BENEFIT_VALUE" to "30", - "GLOSSO_BETA_FOUNDER_BENEFIT_LABEL" to "Precio fundador", - "GLOSSO_BETA_FOUNDER_UNTIL" to "2027-06-30T23:59:59Z", "GLOSSO_MIN_CLIENT_VERSION" to "2300", "GLOSSO_RECOMMENDED_CLIENT_VERSION" to "2400", "GLOSSO_FORCE_UPDATE" to "true", - "GLOSSO_FLAG_PRO" to "true", "GLOSSO_FLAG_SOMETHING_NEW" to "true", + "GLOSSO_FLAG_OTHER" to "off", ), ) - assertEquals(ProductMode.FREE, config.mode) - assertEquals(Instant.parse("2025-01-02T03:04:05Z"), config.beta.endDate) - assertEquals(false, config.beta.founderEnabled) - assertEquals("lifetime_percent", config.beta.founderBenefit.kind) - assertEquals(30, config.beta.founderBenefit.value) - assertNull(config.beta.founderBenefit.until) - assertFalse(config.beta.founderBenefit.grantsProLifetime) - assertEquals("Precio fundador", config.beta.founderBenefit.label) - assertEquals(Instant.parse("2027-06-30T23:59:59Z"), config.beta.founderUntil) assertEquals(2300, config.client.minimumSupportedVersion) assertEquals(2400, config.client.recommendedVersion) assertEquals(true, config.client.forceUpdate) - assertEquals(true, config.flags["pro"]) - assertEquals(true, config.flags["something_new"]) - assertEquals(false, config.flagsAt(Instant.parse("2024-01-01T00:00:00Z"))["beta_banner"]) - } - - @Test - fun `an unparseable date falls back to the safe default`() { - val config = ProductConfig.fromEnv( - mapOf("GLOSSO_BETA_END_DATE" to "not-a-date", "GLOSSO_BETA_FOUNDER_UNTIL" to "2027"), - ) - assertEquals(ProductConfig.DEFAULT_END_DATE, config.beta.endDate) - assertEquals(ProductConfig.DEFAULT_FOUNDER_UNTIL, config.beta.founderUntil) - } - - @Test - fun `the beta end date and the founder window are independent`() { - val config = ProductConfig.fromEnv( - mapOf( - "GLOSSO_BETA_END_DATE" to "2027-06-30T23:59:59Z", - "GLOSSO_BETA_FOUNDER_UNTIL" to "2027-12-31T23:59:59Z", - ), - ) - assertFalse(config.betaActiveAt(Instant.parse("2027-09-01T00:00:00Z"))) - assertTrue(config.isFounderAlta(Instant.parse("2027-09-01T00:00:00Z"))) - assertTrue(config.isFounderAlta(Instant.parse("2027-12-31T23:59:59Z"))) - assertFalse(config.isFounderAlta(Instant.parse("2028-01-01T00:00:00Z"))) - - // And the other way round: the founder window can close before the beta. - val early = ProductConfig.fromEnv(mapOf("GLOSSO_BETA_FOUNDER_UNTIL" to "2027-01-31T23:59:59Z")) - assertTrue(early.betaActiveAt(Instant.parse("2027-03-01T00:00:00Z"))) - assertFalse(early.isFounderAlta(Instant.parse("2027-03-01T00:00:00Z"))) - val disabled = ProductConfig.fromEnv(mapOf("GLOSSO_BETA_FOUNDER_ENABLED" to "false")) - assertFalse(disabled.isFounderAlta(Instant.parse("2026-10-01T00:00:00Z"))) + assertEquals(mapOf("other" to false, "something_new" to true), config.flags) } @Test fun `blank variables from docker compose count as unset`() { val config = ProductConfig.fromEnv( mapOf( - "GLOSSO_PRODUCT_MODE" to "", - "GLOSSO_BETA_END_DATE" to "", - "GLOSSO_BETA_FOUNDER_UNTIL" to " ", - "GLOSSO_BETA_FOUNDER_BENEFIT_KIND" to "", - "GLOSSO_BETA_FOUNDER_BENEFIT_LABEL" to "", - "GLOSSO_FLAG_BETA_BANNER" to "", - "GLOSSO_FLAG_PRO" to "", - "GLOSSO_PLANS_JSON" to "", + "GLOSSO_MIN_CLIENT_VERSION" to "", + "GLOSSO_FORCE_UPDATE" to " ", + "GLOSSO_FLAG_ACCOUNTS" to "", ), ) - assertEquals(ProductMode.BETA, config.mode) - assertEquals(ProductConfig.DEFAULT_END_DATE, config.beta.endDate) - assertEquals(ProductConfig.DEFAULT_FOUNDER_UNTIL, config.beta.founderUntil) - assertEquals("PRO_LIFETIME", config.beta.founderBenefit.kind) - assertEquals("Glosso Pro gratis para siempre", config.beta.founderBenefit.label) - assertEquals(true, config.flagsAt(Instant.parse("2026-10-01T00:00:00Z"))["beta_banner"]) - assertEquals(false, config.flags["pro"]) - assertEquals(ProductConfig.DEFAULT_PLANS, config.plans) + assertEquals(ProductConfig.DEFAULT_MIN_VERSION, config.client.minimumSupportedVersion) + assertEquals(false, config.client.forceUpdate) + assertTrue(config.flags.isEmpty()) } @Test @@ -143,94 +65,18 @@ class ProductConfigTest { assertEquals(2300, config.client.recommendedVersion) } - @Test - fun `plan entitlements are overridden key by key and never lose a key`() { - val config = ProductConfig.fromEnv( - mapOf( - "GLOSSO_PLANS_JSON" to - """{"FREE":{"exercises":"STANDARD","offline_packs":false},""" + - """"PRO":{"personalized_learning":true},"ENTERPRISE":{"exercises":"ALL"}}""", - ), - ) - val free = config.plans.getValue("FREE") - assertEquals("STANDARD", free.getValue("exercises").jsonPrimitive.content) - assertEquals(false, free.getValue("offline_packs").jsonPrimitive.boolean) - assertEquals("LIMITED", free.getValue("pronunciation_analysis").jsonPrimitive.content) - assertEquals(true, config.plans.getValue("PRO").getValue("personalized_learning").jsonPrimitive.boolean) - assertEquals(ProductConfig.DEFAULT_PLANS.getValue("PERSONALIZED"), config.plans.getValue("PERSONALIZED")) - assertEquals(setOf("FREE", "PRO", "PERSONALIZED"), config.plans.keys) - - val broken = ProductConfig.fromEnv(mapOf("GLOSSO_PLANS_JSON" to "[1,2]")) - assertEquals(ProductConfig.DEFAULT_PLANS, broken.plans) - } - - @Test - fun `limits and pricing can be fixed from configuration`() { - val config = ProductConfig.fromEnv( - mapOf( - "GLOSSO_LIMITS_JSON" to - """{"FREE":{"pronunciation_analysis_per_day":20,"history_days":30}}""", - "GLOSSO_PRICING_JSON" to - """{"PRO":{"monthly":null,"yearly":null,"currency":"EUR"}}""", - ), - ) - - assertNotNull(config.limits) - assertNotNull(config.pricing) - val body = config.toJson() - assertTrue(body.contains("\"pronunciation_analysis_per_day\":20"), body) - assertTrue(body.contains("\"history_days\":30"), body) - assertTrue(body.contains("\"currency\":\"EUR\""), body) - } - - @Test - fun `an unparseable limits or pricing blob stays null`() { - val config = ProductConfig.fromEnv( - mapOf( - "GLOSSO_LIMITS_JSON" to "{not json", - "GLOSSO_PRICING_JSON" to " ", - ), - ) - assertNull(config.limits) - assertNull(config.pricing) - } - @Test fun `the served JSON is the contract shape`() { val json = Json.parseToJsonElement( - ProductConfig.fromEnv(emptyMap()).toJson(Instant.parse("2026-10-01T00:00:00Z")), + ProductConfig.fromEnv(mapOf("GLOSSO_FLAG_ACCOUNTS" to "true")).toJson(), ).jsonObject + assertEquals(setOf("schema", "mode", "beta", "flags", "client"), json.keys) assertEquals(1, json.getValue("schema").jsonPrimitive.int) + // Older app builds unlock every lesson only while they read an open beta. assertEquals("BETA", json.getValue("mode").jsonPrimitive.content) - - val beta = json.getValue("beta").jsonObject - assertEquals(true, beta.getValue("active").jsonPrimitive.boolean) - assertEquals("2027-12-31T23:59:59Z", beta.getValue("endDate").jsonPrimitive.content) - assertEquals(true, beta.getValue("founderEnabled").jsonPrimitive.boolean) - val benefit = beta.getValue("founderBenefit").jsonObject - assertEquals("PRO_LIFETIME", benefit.getValue("kind").jsonPrimitive.content) - assertTrue(benefit.getValue("value") is JsonNull) - assertTrue(benefit.getValue("until") is JsonNull) - assertEquals("Glosso Pro gratis para siempre", benefit.getValue("label").jsonPrimitive.content) - assertEquals("2027-12-31T23:59:59Z", beta.getValue("founderUntil").jsonPrimitive.content) - - val plans = json.getValue("plans").jsonObject - assertEquals(setOf("FREE", "PRO", "PERSONALIZED"), plans.keys) - assertEquals( - "LIMITED", - plans.getValue("FREE").jsonObject.getValue("pronunciation_analysis").jsonPrimitive.content, - ) - assertEquals("FULL", plans.getValue("PERSONALIZED").jsonObject.getValue("personalization").jsonPrimitive.content) - assertEquals(false, plans.getValue("PRO").jsonObject.getValue("personalized_learning").jsonPrimitive.boolean) - assertEquals(true, plans.getValue("PERSONALIZED").jsonObject.getValue("personalized_learning").jsonPrimitive.boolean) - - assertTrue(json.getValue("limits") is JsonNull) - assertTrue(json.getValue("pricing") is JsonNull) - - val flags = json.getValue("flags").jsonObject - assertEquals(true, flags.getValue("beta_banner").jsonPrimitive.boolean) - assertEquals(false, flags.getValue("pro").jsonPrimitive.boolean) + assertEquals(true, json.getValue("beta").jsonObject.getValue("active").jsonPrimitive.boolean) + assertEquals(true, json.getValue("flags").jsonObject.getValue("accounts").jsonPrimitive.boolean) val client = json.getValue("client").jsonObject assertEquals(2210, client.getValue("minimumSupportedVersion").jsonPrimitive.int) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b3002e1..d01de6b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,12 +109,10 @@ kotlin { } findByName("androidMain")?.dependencies { implementation(libs.ktor.client.android) - // Already in the app: BackHandler and the permission launcher - // (activity-compose) and Google sign-in through Credential Manager. + // Already in the app: BackHandler, the permission launcher and the + // file picker (activity-compose). Google sign-in is not here: it lives + // in :androidApp's playstore flavor, so the F-Droid build has none. implementation(libs.androidx.activity.compose) - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.play.services.auth) - implementation(libs.googleid) // Images over OkHttp, the transport Coil 2 already used in the app. implementation(libs.coil.network.okhttp) } @@ -176,7 +174,7 @@ if (androidEnabled) { // Strings, fonts and images shared by the Android and iOS UI live in // src/commonMain/composeResources. Android-only resources (manifest label, launcher -// icon, splash theme, notifications, Play Billing errors) stay in androidApp/res. +// icon, splash theme, notifications) stay in androidApp/res. compose.resources { publicResClass = true packageOfResClass = "com.glosso.app.resources" diff --git a/shared/src/androidMain/kotlin/com/glosso/app/platform/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/glosso/app/platform/FilePicker.android.kt new file mode 100644 index 0000000..be136b4 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/glosso/app/platform/FilePicker.android.kt @@ -0,0 +1,27 @@ +package com.glosso.app.platform + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import java.io.FileNotFoundException +import okio.source + +@Composable +actual fun rememberFilePicker(onPicked: (List) -> Unit): (() -> Unit)? { + val resolver = LocalContext.current.applicationContext.contentResolver + val latest by rememberUpdatedState(onPicked) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) { + latest( + uris.map { uri -> + PickedFile { resolver.openInputStream(uri)?.source() ?: throw FileNotFoundException(uri.toString()) } + }, + ) + } + } + // The model has no registered MIME type, so every file is offered. + return { launcher.launch(arrayOf("*/*")) } +} diff --git a/shared/src/androidMain/kotlin/com/glosso/app/platform/LocaleFormat.android.kt b/shared/src/androidMain/kotlin/com/glosso/app/platform/LocaleFormat.android.kt index bf7133c..37d67e5 100644 --- a/shared/src/androidMain/kotlin/com/glosso/app/platform/LocaleFormat.android.kt +++ b/shared/src/androidMain/kotlin/com/glosso/app/platform/LocaleFormat.android.kt @@ -5,11 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration import java.text.NumberFormat import java.time.DayOfWeek -import java.time.Instant import java.time.LocalDate -import java.time.ZoneOffset import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.time.format.TextStyle import java.util.Locale @@ -25,12 +22,6 @@ actual fun formatDecimal(value: Double, decimals: Int, languageTag: String?): St actual fun localeLanguage(languageTag: String?): String = localeOf(languageTag).language -actual fun formatLongUtcDate(iso: String): String? = runCatching { - val instant = Instant.parse(iso) - val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG) - instant.atZone(ZoneOffset.UTC).toLocalDate().format(formatter) -}.getOrNull() - actual fun formatWeekdayDayMonth(epochDay: Long, languageTag: String): String { val locale = Locale.forLanguageTag(languageTag) return LocalDate.ofEpochDay(epochDay) diff --git a/shared/src/androidMain/kotlin/com/glosso/app/platform/PlatformUi.android.kt b/shared/src/androidMain/kotlin/com/glosso/app/platform/PlatformUi.android.kt index a84bc8b..a514607 100644 --- a/shared/src/androidMain/kotlin/com/glosso/app/platform/PlatformUi.android.kt +++ b/shared/src/androidMain/kotlin/com/glosso/app/platform/PlatformUi.android.kt @@ -1,19 +1,14 @@ package com.glosso.app.platform -import android.app.Activity import android.app.LocaleManager import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.os.Build import android.os.LocaleList import android.provider.Settings -import android.view.Window -import android.view.WindowManager import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -35,26 +30,6 @@ actual fun rememberReduceMotion(): Boolean { } } -@Composable -actual fun ScreenshotProtection() { - val context = LocalContext.current - DisposableEffect(context) { - val window = context.findActivity()?.window - window.setScreenshotProtected(true) - onDispose { window.setScreenshotProtected(false) } - } -} - -/** `Window.FLAG_SECURE` on/off; a null window is a no-op. */ -fun Window?.setScreenshotProtected(protected: Boolean) { - if (this == null) return - if (protected) { - addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } -} - @Composable actual fun rememberPlatformActions(): PlatformActions { val context = LocalContext.current @@ -72,14 +47,6 @@ private class AndroidPlatformActions(private val context: Context) : PlatformAct ) } - override fun openSubscriptionManagement(productId: String) { - val url = "https://play.google.com/store/account/subscriptions" + - "?sku=$productId&package=${context.packageName}" - runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } - } - override val appLocaleSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU @@ -117,13 +84,3 @@ fun setAppLocale(context: Context, tag: String) { manager.applicationLocales = if (tag.isBlank()) LocaleList.getEmptyLocaleList() else LocaleList.forLanguageTags(tag) } - -actual typealias PlatformActivity = Activity - -@Composable -actual fun currentPlatformActivity(): PlatformActivity? = LocalContext.current.findActivity() - -private tailrec fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - else -> (this as? ContextWrapper)?.baseContext?.findActivity() -} diff --git a/shared/src/androidUnitTest/kotlin/com/glosso/app/BetaCopyTest.kt b/shared/src/androidUnitTest/kotlin/com/glosso/app/BetaCopyTest.kt deleted file mode 100644 index 7709c45..0000000 --- a/shared/src/androidUnitTest/kotlin/com/glosso/app/BetaCopyTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.glosso.app - -import com.glosso.app.product.formatBetaEndDate -import com.glosso.app.resources.* -import java.util.Locale -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class BetaCopyTest { - - private val locales = listOf( - Locale.forLanguageTag("es"), - Locale.ENGLISH, - Locale.FRENCH, - Locale.GERMAN, - ) - - @Test - fun `beta banner strings exist and are localized in all supported languages`() { - for (locale in locales) { - val strings = LocalizedStrings(locale) - - val title = strings.getString(Res.string.beta_open_title) - val freeUntil = strings.getString(Res.string.beta_free_until, "31/12/2027") - val freeDuring = strings.getString(Res.string.beta_free_during) - val founderNote = strings.getString(Res.string.beta_founder_note) - val lifetimeFounder = strings.getString(Res.string.entitlement_lifetime_founder) - val lifetimePro = strings.getString(Res.string.entitlement_lifetime_pro) - - assertNotNull(title) - assertTrue(title.isNotBlank()) - assertTrue(freeUntil.contains("31/12/2027")) - assertTrue(freeDuring.isNotBlank()) - assertTrue(founderNote.isNotBlank()) - assertTrue(lifetimeFounder.isNotBlank()) - assertTrue(lifetimePro.isNotBlank()) - - // The whole course is not claimed to be free forever (personalized is excluded) - assertFalse(freeUntil.lowercase().contains("para siempre")) - assertFalse(freeUntil.lowercase().contains("forever")) - assertFalse(freeDuring.lowercase().contains("para siempre")) - assertFalse(freeDuring.lowercase().contains("forever")) - } - } - - @Test - fun `no beta string hardcodes dates`() { - val dateRegex = Regex("(202[5-9]|diciembre|december|décembre|dezember)", RegexOption.IGNORE_CASE) - - for (locale in locales) { - val strings = LocalizedStrings(locale) - - val rawFreeUntil = strings.getString(Res.string.beta_free_until) - val rawFreeDuring = strings.getString(Res.string.beta_free_during) - val rawFounder = strings.getString(Res.string.beta_founder_note) - - assertFalse("free_until must not hardcode date: $rawFreeUntil", dateRegex.containsMatchIn(rawFreeUntil)) - assertFalse("free_during must not hardcode date: $rawFreeDuring", dateRegex.containsMatchIn(rawFreeDuring)) - assertFalse("founder_note must not hardcode date: $rawFounder", dateRegex.containsMatchIn(rawFounder)) - } - } - - @Test - fun `formatBetaEndDate formats correctly and handles missing or invalid dates`() { - val previousLocale = Locale.getDefault() - try { - Locale.setDefault(Locale.US) - assertEquals("December 31, 2027", formatBetaEndDate("2027-12-31T23:59:59Z")) - assertEquals("December 31, 2026", formatBetaEndDate("2026-12-31T23:59:59Z")) - assertNull(formatBetaEndDate(null)) - assertNull(formatBetaEndDate("")) - assertNull(formatBetaEndDate("invalid-date")) - } finally { - Locale.setDefault(previousLocale) - } - } -} diff --git a/shared/src/androidUnitTest/kotlin/com/glosso/app/FounderRecoveryCopyTest.kt b/shared/src/androidUnitTest/kotlin/com/glosso/app/ChangePhoneCopyTest.kt similarity index 98% rename from shared/src/androidUnitTest/kotlin/com/glosso/app/FounderRecoveryCopyTest.kt rename to shared/src/androidUnitTest/kotlin/com/glosso/app/ChangePhoneCopyTest.kt index 5c6c76b..3817955 100644 --- a/shared/src/androidUnitTest/kotlin/com/glosso/app/FounderRecoveryCopyTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/glosso/app/ChangePhoneCopyTest.kt @@ -16,7 +16,7 @@ import org.robolectric.annotation.Config */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) -class FounderRecoveryCopyTest { +class ChangePhoneCopyTest { private val locales = listOf( Locale.forLanguageTag("es"), diff --git a/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallOfferTest.kt b/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallOfferTest.kt deleted file mode 100644 index 9899f04..0000000 --- a/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallOfferTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.glosso.app - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import com.glosso.app.billing.SubscriptionPlan -import com.glosso.app.designsystem.GlossoTheme -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * With `pricing: null` the server has not put a plan on sale, so the paywall must - * not paint any figure, not even one Play already returned. The offer (and only - * it) is what carries the price, so hiding it is what keeps the "not on sale" - * copy free of numbers. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class PaywallOfferTest { - - @get:Rule - val rule = createComposeRule() - - private fun plan(price: String) = SubscriptionPlan( - productId = "me.shirobyte42.glosso.pro", - title = "Glosso Pro", - description = "The full course", - offerToken = "offer-token", - basePlanId = "base", - offerId = null, - priceFormatted = price, - recurringPeriodIso = "P1M", - freeTrialPeriod = null, - ) - - @Test - fun `no price is painted when the server has nothing on sale`() { - rule.setContent { - GlossoTheme(darkTheme = true) { - PaywallOffer(plan = plan("6,99 €"), loading = false, sellingEnabled = false) - } - } - - rule.onNodeWithText("6,99", substring = true).assertDoesNotExist() - } - - @Test - fun `the Play price is painted only when a plan is on sale`() { - rule.setContent { - GlossoTheme(darkTheme = true) { - PaywallOffer(plan = plan("6,99 €"), loading = false, sellingEnabled = true) - } - } - - rule.onNodeWithText("6,99", substring = true).assertIsDisplayed() - } -} diff --git a/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallStatusCardTest.kt b/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallStatusCardTest.kt deleted file mode 100644 index bfc2496..0000000 --- a/shared/src/androidUnitTest/kotlin/com/glosso/app/PaywallStatusCardTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.glosso.app - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import com.glosso.app.billing.SubscriptionPlan -import com.glosso.app.billing.SubscriptionStatus -import com.glosso.app.designsystem.GlossoTheme -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * The trial status card states the trial without a figure unless the server has - * put a plan on sale. When selling is off, a price Play already returned is a - * number the paywall must not paint, exactly like the offer card next to it. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class PaywallStatusCardTest { - - @get:Rule - val rule = createComposeRule() - - private fun plan(price: String) = SubscriptionPlan( - productId = "me.shirobyte42.glosso.pro", - title = "Glosso Pro", - description = "The full course", - offerToken = "offer-token", - basePlanId = "base", - offerId = null, - priceFormatted = price, - recurringPeriodIso = "P1M", - freeTrialPeriod = null, - ) - - @Test - fun `the trial price is not painted when nothing is on sale`() { - rule.setContent { - GlossoTheme(darkTheme = true) { - StatusCard( - status = SubscriptionStatus.TRIAL, - plan = plan("6,99 €"), - sellingEnabled = false, - ) - } - } - - rule.onNodeWithText("6,99", substring = true).assertDoesNotExist() - } - - @Test - fun `the trial price is painted only when a plan is on sale`() { - rule.setContent { - GlossoTheme(darkTheme = true) { - StatusCard( - status = SubscriptionStatus.TRIAL, - plan = plan("6,99 €"), - sellingEnabled = true, - ) - } - } - - rule.onNodeWithText("6,99", substring = true).assertIsDisplayed() - } -} diff --git a/shared/src/androidUnitTest/kotlin/com/glosso/app/SupportGlossoTest.kt b/shared/src/androidUnitTest/kotlin/com/glosso/app/SupportGlossoTest.kt new file mode 100644 index 0000000..9e47bca --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/com/glosso/app/SupportGlossoTest.kt @@ -0,0 +1,60 @@ +package com.glosso.app + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.glosso.app.designsystem.GlossoTheme +import com.glosso.app.resources.* +import java.util.Locale +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The voluntary "Support Glosso" entry only exists once a Ko-fi page does: with + * a blank URL neither the You row nor the About section renders anything. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SupportGlossoTest { + + @get:Rule + val rule = createComposeRule() + + private val title get() = LocalizedStrings(Locale.getDefault()).getString(Res.string.support_title) + + @Test + fun `the entry is hidden while the Ko-fi URL is blank`() { + rule.setContent { + GlossoTheme(darkTheme = true) { + SupportGlossoRow(url = "") + SupportGlossoSection(onOpen = {}, url = " ") + } + } + rule.onNodeWithText(title).assertDoesNotExist() + } + + @Test + fun `the entry shows once a Ko-fi URL is set`() { + rule.setContent { + GlossoTheme(darkTheme = true) { SupportGlossoRow(url = "https://ko-fi.com/example") } + } + rule.onNodeWithText(title).assertIsDisplayed() + } + + @Test + fun `the support copy exists in every app language, without em dashes`() { + for (tag in listOf("en", "es", "fr", "de")) { + val strings = LocalizedStrings(Locale.forLanguageTag(tag)) + for (key in listOf(Res.string.support_title, Res.string.support_subtitle, Res.string.support_link)) { + val value = strings.getString(key) + assertTrue("$tag: $key is blank", value.isNotBlank()) + assertFalse("$tag: $key uses an em dash", value.contains('\u2014')) + } + } + } +} diff --git a/shared/src/commonMain/composeResources/files/licenses.json b/shared/src/commonMain/composeResources/files/licenses.json index b9fb820..dd31df6 100644 --- a/shared/src/commonMain/composeResources/files/licenses.json +++ b/shared/src/commonMain/composeResources/files/licenses.json @@ -1610,19 +1610,6 @@ "licenseTextUrl": null, "notice": "Generated brand artwork, no third-party source imagery. Model weights are Apache-2.0; the non-commercial 9B variant is never used (see tools/licensing and docs/LICENSING.md)." }, - { - "id": "com.android.billingclient:billing", - "name": "Google Play Billing Library", - "author": "Google LLC", - "url": "https://developer.android.com/google/play/billing", - "usage": "In-app subscriptions. Governed by the Android SDK License, not Apache-2.0.", - "distribution": "app", - "license": "Android-SDK", - "licenseName": "Android Software Development Kit License", - "licenseText": null, - "licenseTextUrl": "https://developer.android.com/studio/terms.html", - "notice": null - }, { "id": "com.caverock:androidsvg-aar", "name": "androidsvg-aar", @@ -1662,45 +1649,6 @@ "licenseTextUrl": null, "notice": null }, - { - "id": "com.google.android.datatransport:transport-api", - "name": "transport-api", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, - { - "id": "com.google.android.datatransport:transport-backend-cct", - "name": "transport-backend-cct", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, - { - "id": "com.google.android.datatransport:transport-runtime", - "name": "transport-runtime", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, { "id": "com.google.android.gms:play-services-auth", "name": "play-services-auth", @@ -1805,32 +1753,6 @@ "licenseTextUrl": "https://developer.android.com/studio/terms.html", "notice": null }, - { - "id": "com.google.android.gms:play-services-location", - "name": "play-services-location", - "author": "Google LLC", - "url": "https://developer.android.com/studio/terms.html", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Android-SDK", - "licenseName": "Android Software Development Kit License", - "licenseText": null, - "licenseTextUrl": "https://developer.android.com/studio/terms.html", - "notice": null - }, - { - "id": "com.google.android.gms:play-services-places-placereport", - "name": "play-services-places-placereport", - "author": "Google LLC", - "url": "https://developer.android.com/studio/terms.html", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Android-SDK", - "licenseName": "Android Software Development Kit License", - "licenseText": null, - "licenseTextUrl": "https://developer.android.com/studio/terms.html", - "notice": null - }, { "id": "com.google.android.gms:play-services-tasks", "name": "play-services-tasks", @@ -1857,58 +1779,6 @@ "licenseTextUrl": "https://developer.android.com/studio/terms.html", "notice": null }, - { - "id": "com.google.android.play:review-ktx", - "name": "Google Play In-App Review", - "author": "Google LLC", - "url": "https://developer.android.com/guide/playcore/in-app-review", - "usage": "In-app review prompt. Governed by the Play Core SDK Terms of Service.", - "distribution": "app", - "license": "Play-Core-ToS", - "licenseName": "Play Core Software Development Kit Terms of Service", - "licenseText": null, - "licenseTextUrl": "https://developer.android.com/guide/playcore/in-app-review", - "notice": null - }, - { - "id": "com.google.firebase:firebase-encoders", - "name": "firebase-encoders", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, - { - "id": "com.google.firebase:firebase-encoders-json", - "name": "firebase-encoders-json", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, - { - "id": "com.google.firebase:firebase-encoders-proto", - "name": "firebase-encoders-proto", - "author": "Google LLC", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, { "id": "com.google.guava:listenablefuture", "name": "listenablefuture", @@ -2884,19 +2754,6 @@ "licenseTextUrl": null, "notice": null }, - { - "id": "javax.inject:javax.inject", - "name": "javax.inject", - "author": "JSR-330 Expert Group", - "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", - "usage": "Transitive Android runtime dependency.", - "distribution": "app", - "license": "Apache-2.0", - "licenseName": "Apache License 2.0", - "licenseText": "Apache-2.0", - "licenseTextUrl": null, - "notice": null - }, { "id": "junit:junit", "name": "JUnit 4", diff --git a/shared/src/commonMain/composeResources/files/privacy/privacy-de.md b/shared/src/commonMain/composeResources/files/privacy/privacy-de.md index 657f497..6d4c38a 100644 --- a/shared/src/commonMain/composeResources/files/privacy/privacy-de.md +++ b/shared/src/commonMain/composeResources/files/privacy/privacy-de.md @@ -33,7 +33,7 @@ Der Server **speichert die IP nicht** in der Ereignistabelle. Serverereignisse ## Konto: Weiter mit Google (optional) -Es nimmt das Konto (und den Beta Founder Status) auf ein neues Handy mit. Es ist +Es nimmt das Konto auf ein neues Handy mit. Es ist optional; die App funktioniert ohne es genauso. - **Weiter mit Google**: Die Person wählt ein Google-Konto auf dem Handy @@ -86,8 +86,7 @@ optional; die App funktioniert ohne es genauso. beendet, Lektion begonnen, beendet oder verlassen, Übung beendet (Art, 10-Punkte-Bereich der Bewertung, gemeistert oder nicht), auf dem Telefon bewerteter Ausspracheversuch (Bewertungsbereich, gemeistert oder nicht, IDs - der Fehlerregeln), Mikrofon verweigert, Pro-Angebot gesehen, Pro-Schaltfläche - getippt und Kauf begonnen oder fehlgeschlagen. + der Fehlerregeln) und Mikrofon verweigert. Ab App 2.5.4 sendet die App außerdem: diff --git a/shared/src/commonMain/composeResources/files/privacy/privacy-en.md b/shared/src/commonMain/composeResources/files/privacy/privacy-en.md index 2397982..d81c73d 100644 --- a/shared/src/commonMain/composeResources/files/privacy/privacy-en.md +++ b/shared/src/commonMain/composeResources/files/privacy/privacy-en.md @@ -32,7 +32,7 @@ The server **does not store the IP** in the events table. Server events ## Account: Continue with Google (optional) -It carries the account (and the Beta Founder status) to a new phone. It is +It carries the account to a new phone. It is optional; the app works the same without it. - **Continue with Google**: the user picks a Google account on the phone @@ -83,9 +83,8 @@ optional; the app works the same without it. - The events the app sends: app opened, session length, onboarding started and finished, lesson started, finished or left, exercise finished (kind, a 10-point score band, mastered or not), pronunciation attempt scored on the - phone (score band, mastered or not, ids of the error rules), microphone - permission denied, paywall seen, Pro button tapped and purchase started or - failed. + phone (score band, mastered or not, ids of the error rules) and microphone + permission denied. From app 2.5.4, the app also sends: diff --git a/shared/src/commonMain/composeResources/files/privacy/privacy-es.md b/shared/src/commonMain/composeResources/files/privacy/privacy-es.md index 4b5236d..5962386 100644 --- a/shared/src/commonMain/composeResources/files/privacy/privacy-es.md +++ b/shared/src/commonMain/composeResources/files/privacy/privacy-es.md @@ -1,4 +1,4 @@ -# Privacidad y analitica - Glosso Pro +# Privacidad y analitica - Glosso Resumen de que datos trata el producto y como declararlo en Google Play. No es asesoramiento legal. @@ -32,7 +32,7 @@ El servidor **no almacena la IP** en la tabla de eventos. Los eventos de servido ## Cuenta: entrar con Google (opcional) -Sirve para llevarse la cuenta (y el estatus Beta Founder) a un movil nuevo. Es +Sirve para llevarse la cuenta a un movil nuevo. Es opcional; sin ello la app funciona igual. - **Entrar con Google**: el usuario elige una cuenta de Google del movil (Android @@ -83,8 +83,7 @@ opcional; sin ello la app funciona igual. empezado y terminado, leccion empezada, terminada o abandonada, ejercicio terminado (tipo, franja de nota de 10 puntos, dominado o no), intento de pronunciacion puntuado en el movil (franja de nota, dominado o no, ids de las - reglas de error), permiso de microfono denegado, muro de pago visto, boton de - Pro pulsado y compra empezada o fallida. + reglas de error) y permiso de microfono denegado. Desde la app 2.5.4, la app manda ademas: diff --git a/shared/src/commonMain/composeResources/files/privacy/privacy-fr.md b/shared/src/commonMain/composeResources/files/privacy/privacy-fr.md index 7aa1635..8603c4a 100644 --- a/shared/src/commonMain/composeResources/files/privacy/privacy-fr.md +++ b/shared/src/commonMain/composeResources/files/privacy/privacy-fr.md @@ -34,8 +34,7 @@ sans audio. ## Compte : continuer avec Google (facultatif) -Il permet d'emporter le compte (et le statut Beta Founder) sur un nouveau -téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. +Il permet d'emporter le compte sur un nouveau téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. - **Continuer avec Google** : l'utilisateur choisit un compte Google du téléphone (Android Credential Manager). Seule l'identité est demandée : le @@ -88,8 +87,7 @@ téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. commencé et terminé, leçon commencée, terminée ou quittée, exercice terminé (type, tranche de note de 10 points, maîtrisé ou non), essai de prononciation noté sur le téléphone (tranche de note, maîtrisé ou non, identifiants des - règles d'erreur), micro refusé, offre Pro vue, bouton Pro touché et achat - commencé ou échoué. + règles d'erreur) et micro refusé. À partir de l'app 2.5.4, l'app envoie aussi : diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 06aa75a..e483eab 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -38,8 +38,6 @@ Dein Profil ist noch nicht bereit. Sag ein paar Sätze und es füllt sich. Derzeit keine schwachen Laute. Die schwächsten zuerst, aus deinen eigenen Aufnahmen. - Glosso Pro ansehen - Dein phonetisches Profil ist Pro. %1$d Laut wiederholen %1$d Laute wiederholen @@ -130,7 +128,6 @@ Für offline heruntergeladen Für offline herunterladen Fertig - Gesperrte Lektion Lektion schließen Offline Wörter @@ -391,6 +388,9 @@ Lizenzen Glosso basiert auf Open-Source-Komponenten und Inhalten Dritter. Ihre Lizenzen und Namensnennungen sind hier in der App aufgeführt. Open-Source-Lizenzen und Namensnennungen + Glosso unterstützen + Glosso ist kostenlos und Open Source. Ein Kaffee hilft, die Server am Laufen zu halten. + Einen Kaffee auf Ko-fi spendieren Vollständige Bedingungen lesen Wähle das passende Wort @@ -503,79 +503,7 @@ Geschichten Universität - - Glosso Pro - GRATIS - Mit Pro freischalten - Das ist Glosso Pro - Ganz A1 und das freie Üben bleiben offen. Ab A2 sind dein personalisiertes Üben und dein phonetisches Profil Pro. - Der ganze Kurs und Offline-Downloads - Kostenlose Testphase aktiv - Abo aktiv - Prüfe deine Zahlungsmethode in Google Play - Gekündigt, Zugriff bis zum Ende des Zeitraums - Zahlung in Google Play ausstehend - Der ganze Kurs, von A1 bis B2. - Ganz A1 ist kostenlos und bleibt es. Glosso Pro schaltet A2 und den Rest des Kurses sowie Offline-Downloads zum Lernen frei. - Du hast A1 abgeschlossen. Ein guter Moment, weiterzumachen. - Ganz A1 ist kostenlos und bleibt es. Glosso Pro schaltet %1$s und den Rest des Kurses sowie Offline-Downloads zum Lernen frei. - A2, B1, B2 und der ganze Kurs nach A1 - Downloads zum Offline-Lernen - Übung und phonetisches Profil inklusive - Monatliches Abo - Google-Play-Preis wird geladen… - Preis nicht verfügbar. Prüfe deine Verbindung. - %1$s gratis starten - Für %1$s abonnieren - Abonnieren - Käufe wiederherstellen - Abo verwalten oder kündigen - Kostenlose Testphase aktiv. Danach %1$s. - Kostenlose Testphase aktiv. - Abo aktiv. - Es gibt ein Problem mit deiner Zahlung. Aktualisiere deine Methode in Google Play, um den Zugriff zu behalten. - Abo gekündigt. Du behältst den Zugriff bis zum Ende des bezahlten Zeitraums. - Zahlung ausstehend. Sie wird geöffnet, sobald Google Play sie bestätigt. - Die Zahlung wird deinem Google-Play-Konto belastet. Das Abo verlängert sich automatisch %1$s, bis du kündigst. - Die Zahlung wird deinem Google-Play-Konto belastet. Das Abo verlängert sich automatisch, bis du kündigst. - Die kostenlose Testphase von %1$s verlängert sich automatisch; kündige vorher, um nicht zu zahlen. - Verwalte oder kündige das Abo in Google Play > Abonnements. - Schließen - %1$s %2$s - %1$s gratis, dann %2$s - %1$s gratis - - %1$d Tag - %1$d Tage - - - %1$d Woche - %1$d Wochen - - - %1$d Monat - %1$d Monate - - - %1$d Jahr - %1$d Jahre - - - pro Tag - alle %1$d Tage - - - pro Woche - alle %1$d Wochen - - - pro Monat - alle %1$d Monate - - - pro Jahr - alle %1$d Jahre - + Geschriebene Aussprache Zeigt die IPA-Lautschrift unter jedem Satz und jedem Laut, in der Übung und im Profil. Schalte sie aus, wenn sie dich ablenkt. Geschriebene Aussprache anzeigen (IPA) @@ -631,18 +559,9 @@ Pausiert Downloads über mobile Daten, damit nichts unbemerkt verbraucht wird. Du kannst einen einzelnen Download über seine Benachrichtigung erlauben. Wird geladen - - Glosso ist in der Beta - Kostenlos bis zum %1$s. - Kostenlos während der Beta. - Als Beta Founder erhältst du Glosso Pro für immer kostenlos. Das Personalisierte Paket ist nicht enthalten. - Du bist Beta Founder: du hast Glosso Pro für immer kostenlos. Das Personalisierte Paket ist nicht enthalten. - Dauerhafter Zugriff auf Glosso Pro - Dauerhafter Zugriff auf Glosso Pro (Beta Founder) - Beta + Eine neue Version von Glosso ist verfügbar. Das Update wird empfohlen, aber alles funktioniert weiter. Ausblenden - Die Tarife sind noch nicht im Verkauf. Punktzahl: %1$d von 100 @@ -663,15 +582,15 @@ Konto Handy wechseln - Nimm dein Konto und deinen Beta Founder Status mit + Nimm dein Konto auf ein neues Handy mit Handy wechseln - Übertrage dein Konto und deinen Beta Founder Status auf ein neues Handy. + Verknüpfe dein Konto mit Google, um es auf ein neues Handy mitzunehmen. Mit deinem Google-Konto Wähle eines der Google-Konten auf diesem Handy, ganz ohne Passwort. Mach auf dem neuen Handy dasselbe mit demselben Konto. Verknüpft mit %1$s. Melde dich auf dem neuen Handy mit genau diesem Konto an. Weiter mit Google Wir speichern nur die Kennung, die Google deinem Konto gibt, weder deine E-Mail noch deinen Namen. Kein Zugriff auf Gmail oder Drive. - Fertig. Dein Konto und dein Beta Founder Status hängen jetzt an deinem Google-Konto. + Fertig. Dein Konto hängt jetzt an deinem Google-Konto. Auf diesem Handy ist kein Google-Konto. Füge eins in den Systemeinstellungen hinzu. Die Anmeldung mit Google hat nicht geklappt. Versuch es noch einmal. Dieses Handy ist schon mit einem anderen Google-Konto verknüpft. Melde dich mit diesem an. @@ -682,4 +601,10 @@ Meine Nutzungsdaten löschen Deine Nutzungsdaten wurden gelöscht. Löschen gerade nicht möglich. Versuche es mit Verbindung erneut. + Aus Datei importieren + Modell wird geprüft und installiert… + Ein Teil des Pakets fehlt noch. Wähle beide Dateien: das Modell (.onnx) und phoneme_vocab.json. + Eine Datei gehört nicht zum Bewertungsmodell von Glosso oder ist beschädigt. Wähle das Modell (.onnx) und phoneme_vocab.json. + Die Datei konnte nicht gelesen werden. Versuch es noch einmal. + Nutzungsstatistiken sind aus. Wenn du helfen willst, den Kurs zu verbessern, kannst du sie in den Einstellungen einschalten: Sie gehen nur an unsere eigenen Server (ohne Werbung, ohne Dritte), verknüpft mit einer zufälligen Installations-ID. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 4effaad..a47592e 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -38,8 +38,6 @@ Tu perfil aún no está listo. Di unas frases y se completará. Ahora mismo no hay sonidos débiles. De más flojo a menos, con tus propias grabaciones. - Ver Glosso Pro - Tu perfil fonético es Pro. Repasar %1$d sonido Repasar %1$d sonidos @@ -112,7 +110,6 @@ Descargado para uso sin conexión Descargar para uso sin conexión Terminada - Lección bloqueada Cerrar lección Sin conexión Palabras @@ -392,6 +389,9 @@ Licencias Glosso se apoya en componentes de código abierto y activos de terceros. Sus licencias y atribuciones se listan aquí, dentro de la app. Licencias y atribuciones de código abierto + Apoya a Glosso + Glosso es gratis y de código abierto. Un café ayuda a mantener los servidores en marcha. + Invitar a un café en Ko-fi Leer los términos completos Elige la palabra que encaja @@ -504,87 +504,7 @@ Relatos Universidad - - Glosso Pro - GRATIS - Desbloquear con Pro - Esto es Glosso Pro - Todo el A1 y la práctica libre siguen abiertos. El A2 en adelante, tu práctica personalizada y tu perfil fonético son Pro. - El curso completo y las descargas offline - Prueba gratis activa - Suscripción activa - Revisa tu método de pago en Google Play - Cancelada, con acceso hasta el final del periodo - Pago pendiente de Google Play - El curso completo, del A1 al B2. - Todo el A1 es gratis y seguirá siéndolo. Glosso Pro abre el A2 y el resto del curso, además de las descargas para estudiar sin conexión. - Has terminado el A1. Un buen momento para continuar. - Todo el A1 es gratis y seguirá siéndolo. Glosso Pro abre el %1$s y el resto del curso, además de las descargas para estudiar sin conexión. - El A2, B1, B2 y todo el curso a partir del A1 - Descargas para estudiar sin conexión - Práctica y perfil fonético incluidos - Suscripción mensual - Cargando el precio de Google Play… - Precio no disponible. Comprueba tu conexión. - Empezar %1$s gratis - Suscribirme por %1$s - Suscribirme - Restaurar compras - Gestionar o cancelar la suscripción - Prueba gratis activa. Se convertirá en %1$s al terminar. - Prueba gratis activa. - Suscripción activa. - Hay un problema con tu pago. Actualiza tu método en Google Play para no perder el acceso. - Suscripción cancelada. Mantienes el acceso hasta que acabe el periodo pagado. - Pago pendiente. Se abrirá cuando Google Play confirme el cobro. - El pago se cobra en tu cuenta de Google Play. La suscripción se renueva automáticamente %1$s hasta que la canceles. - El pago se cobra en tu cuenta de Google Play. La suscripción se renueva automáticamente hasta que la canceles. - La prueba gratuita de %1$s se renueva automáticamente; cancela antes de que termine para no pagar. - Gestiona o cancela la suscripción en Google Play > Suscripciones. - Cerrar - %1$s %2$s - %1$s gratis, luego %2$s - %1$s gratis - - %1$d día - %1$d días - %1$d días - - - %1$d semana - %1$d semanas - %1$d semanas - - - %1$d mes - %1$d meses - %1$d meses - - - %1$d año - %1$d años - %1$d años - - - al día - cada %1$d días - cada %1$d días - - - cada semana - cada %1$d semanas - cada %1$d semanas - - - al mes - cada %1$d meses - cada %1$d meses - - - al año - cada %1$d años - cada %1$d años - + Pronunciación escrita Muestra la transcripción IPA bajo cada frase y cada sonido, en la práctica y en tu perfil. Desactívala si te distrae. Mostrar pronunciación escrita (IPA) @@ -641,18 +561,9 @@ Pausa las descargas con datos móviles para no gastar megas sin que lo sepas. Puedes permitir una descarga concreta desde su notificación. Descargando - - Glosso está en beta - Gratis hasta el %1$s. - Gratis durante la beta. - Como Beta Founder tendrás Glosso Pro gratis para siempre. El plan Personalizado no está incluido. - Eres Beta Founder: tienes Glosso Pro gratis para siempre. El plan Personalizado no está incluido. - Acceso permanente a Glosso Pro - Acceso permanente a Glosso Pro (Beta Founder) - Beta + Hay una versión nueva de Glosso. Recomendamos actualizar, pero todo sigue funcionando. Descartar - Los planes aún no están a la venta. Puntuación: %1$d de 100 @@ -673,15 +584,15 @@ Cuenta Cambiar de móvil - Llévate tu cuenta y tu estatus de Beta Founder + Llévate tu cuenta a un móvil nuevo Cambiar de móvil - Lleva tu cuenta y tu estatus de Beta Founder a un móvil nuevo. + Vincula tu cuenta a Google para llevarla contigo a un móvil nuevo. Con tu cuenta de Google Elige una de las cuentas de Google de este móvil, sin contraseñas. En el móvil nuevo, haz lo mismo con la misma cuenta. Vinculada a %1$s. En el móvil nuevo, entra con esta misma cuenta. Entrar con Google Solo guardamos el identificador que Google asigna a tu cuenta, no tu correo ni tu nombre. Sin acceso a Gmail ni a Drive. - Listo. Tu cuenta y tu estatus de Beta Founder ya van con tu cuenta de Google. + Listo. Tu cuenta ya va con tu cuenta de Google. No hay ninguna cuenta de Google en este móvil. Añádela en los ajustes del sistema. No se ha podido entrar con Google. Inténtalo de nuevo. Este móvil ya está vinculado a otra cuenta de Google. Entra con esa cuenta. @@ -692,4 +603,10 @@ Borrar mis datos de uso Tus datos de uso se han borrado. No se han podido borrar ahora. Inténtalo de nuevo con conexión. + Importar desde un archivo + Comprobando e instalando el modelo… + Aún falta parte del paquete. Elige los dos archivos: el modelo (.onnx) y phoneme_vocab.json. + Un archivo no es parte del modelo de puntuación de Glosso o está dañado. Elige el modelo (.onnx) y phoneme_vocab.json. + No se pudo leer el archivo. Inténtalo de nuevo. + Las estadísticas de uso están desactivadas. Si quieres ayudar a mejorar el curso, puedes activarlas en Ajustes: solo van a nuestros propios servidores (sin anuncios ni terceros), ligadas a un identificador de instalación aleatorio. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 295b618..732ed0f 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -38,8 +38,6 @@ Ton profil n'est pas encore prêt. Dis quelques phrases et il se remplira. Aucun son faible pour l'instant. Les plus faibles d'abord, d'après tes propres enregistrements. - Voir Glosso Pro - Ton profil phonétique est Pro. Revoir %1$d son Revoir %1$d sons @@ -131,7 +129,6 @@ Téléchargé pour l'usage hors ligne Télécharger pour l'usage hors ligne Terminée - Leçon verrouillée Fermer la leçon Hors ligne Mots @@ -392,6 +389,9 @@ Licences Glosso s'appuie sur des composants open source et des ressources tierces. Leurs licences et attributions sont listées ici, dans l'application. Licences et attributions open source + Soutenir Glosso + Glosso est gratuit et open source. Un café aide à faire tourner les serveurs. + Offrir un café sur Ko-fi Lire les conditions complètes Choisis le mot qui convient @@ -504,89 +504,7 @@ Récits Université - - Glosso Pro - GRATUIT - Débloquer avec Pro - Voici Glosso Pro - Tout le A1 et l'entraînement libre restent ouverts. À partir du A2, votre entraînement personnalisé et votre profil phonétique sont Pro. - Le cours complet et les téléchargements hors ligne - Essai gratuit actif - Abonnement actif - Vérifie ton moyen de paiement dans Google Play - Annulé, accès jusqu'à la fin de la période - Paiement en attente dans Google Play - Tout le cours, de A1 à B2. - Tout le niveau A1 est gratuit et le restera. Glosso Pro débloque le A2 et le reste du cours, ainsi que les téléchargements pour étudier hors ligne. - Tu as terminé le A1. Un bon moment pour continuer. - Tout le niveau A1 est gratuit et le restera. Glosso Pro débloque le %1$s et le reste du cours, ainsi que les téléchargements pour étudier hors ligne. - Le A2, B1, B2 et tout le cours au-delà du A1 - Téléchargements pour étudier hors ligne - Entraînement et profil phonétique inclus - Abonnement mensuel - Chargement du prix Google Play… - Prix indisponible. Vérifie ta connexion. - Commencer %1$s gratuit - S'abonner pour %1$s - S'abonner - Restaurer les achats - Gérer ou annuler l'abonnement - Essai gratuit actif. Il deviendra %1$s à la fin. - Essai gratuit actif. - Abonnement actif. - Un problème est survenu avec ton paiement. Mets à jour ton moyen dans Google Play pour garder l'accès. - Abonnement annulé. Tu gardes l'accès jusqu'à la fin de la période payée. - Paiement en attente. Il s'ouvrira quand Google Play le confirmera. - Le paiement est débité sur ton compte Google Play. L'abonnement se renouvelle automatiquement %1$s jusqu'à ce que tu annules. - Le paiement est débité sur ton compte Google Play. L'abonnement se renouvelle automatiquement jusqu'à ce que tu annules. - L'essai gratuit de %1$s se renouvelle automatiquement ; annule avant la fin pour ne pas payer. - Gère ou annule l'abonnement dans Google Play > Abonnements. - Fermer - %1$s %2$s - %1$s gratuit, puis %2$s - %1$s gratuit - - %1$d jour - %1$d jours - %1$d jours - - - %1$d semaine - %1$d semaines - %1$d semaines - - - %1$d mois - %1$d mois - %1$d mois - - - %1$d an - %1$d ans - %1$d ans - - - - par jour - tous les %1$d jours - tous les %1$d jours - - - par semaine - toutes les %1$d semaines - toutes les %1$d semaines - - - par mois - tous les %1$d mois - tous les %1$d mois - - - par an - tous les %1$d ans - tous les %1$d ans - + Prononciation écrite Affiche la transcription IPA sous chaque phrase et chaque son, à l'entraînement et dans ton profil. Désactive-la si elle te distrait. Afficher la prononciation écrite (IPA) @@ -643,18 +561,9 @@ Met en pause les téléchargements en données mobiles pour ne rien consommer à votre insu. Vous pouvez autoriser un téléchargement précis depuis sa notification. Téléchargement - - Glosso est en bêta - Gratuit jusqu'au %1$s. - Gratuit pendant la bêta. - En tant que Beta Founder, tu auras Glosso Pro gratuit pour toujours. L'offre Personnalisée n'est pas incluse. - Tu es Beta Founder : tu as Glosso Pro gratuit pour toujours. L'offre Personnalisée n'est pas incluse. - Accès permanent à Glosso Pro - Accès permanent à Glosso Pro (Beta Founder) - Bêta + Une nouvelle version de Glosso est disponible. La mise à jour est recommandée, mais tout continue de fonctionner. Ignorer - Les offres ne sont pas encore en vente. Score : %1$d sur 100 @@ -675,15 +584,15 @@ Compte Changer de téléphone - Emporte ton compte et ton statut Beta Founder + Emporte ton compte sur un nouveau téléphone Changer de téléphone - Transfère ton compte et ton statut Beta Founder sur un nouveau téléphone. + Associe ton compte à Google pour l'emporter sur un nouveau téléphone. Avec ton compte Google Choisis un des comptes Google de ce téléphone, sans mot de passe. Sur le nouveau téléphone, fais pareil avec le même compte. Lié à %1$s. Sur le nouveau téléphone, connecte-toi avec ce même compte. Continuer avec Google On garde seulement l'identifiant que Google attribue à ton compte, ni ton e-mail ni ton nom. Aucun accès à Gmail ni à Drive. - C'est fait. Ton compte et ton statut Beta Founder suivent maintenant ton compte Google. + C'est fait. Ton compte suit maintenant ton compte Google. Aucun compte Google sur ce téléphone. Ajoutes-en un dans les réglages du système. Impossible de se connecter avec Google. Réessaie. Ce téléphone est déjà lié à un autre compte Google. Connecte-toi avec celui-là. @@ -694,4 +603,10 @@ Supprimer mes données d'utilisation Vos données d'utilisation ont été supprimées. Suppression impossible pour l'instant. Réessayez avec une connexion. + Importer depuis un fichier + Vérification et installation du modèle… + Une partie du pack manque encore. Choisissez les deux fichiers : le modèle (.onnx) et phoneme_vocab.json. + Un fichier ne fait pas partie du modèle d'évaluation de Glosso ou il est endommagé. Choisissez le modèle (.onnx) et phoneme_vocab.json. + Impossible de lire ce fichier. Réessayez. + Les statistiques d'utilisation sont désactivées. Pour aider à améliorer le cours, vous pouvez les activer dans les Réglages : elles ne vont qu'à nos propres serveurs (sans publicité ni tiers), liées à un identifiant d'installation aléatoire. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index ccd9f5d..55d032e 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -48,8 +48,6 @@ Your profile isn't ready yet. Say a few phrases and it will fill in. No weak sounds right now. Weakest first, from your own recordings. - See Glosso Pro - Your phonetic profile is Pro. Review %1$d sound Review %1$d sounds @@ -126,7 +124,6 @@ Downloaded for offline use Download for offline use Finished - Locked lesson Close lesson Offline Words @@ -415,6 +412,9 @@ Licenses Glosso is built on open-source components and third-party assets. Their licenses and attributions are listed here, inside the app. Open-source licenses and attributions + Support Glosso + Glosso is free and open source. A coffee helps keep the servers running. + Buy a coffee on Ko-fi Read the full terms @@ -530,79 +530,7 @@ Storytelling University - - Glosso Pro - FREE - Unlock with Pro - This is Glosso Pro - All of A1 and free practice stay open. A2 and beyond, your personalized practice and your phonetic profile are Pro. - The full course and offline downloads - Free trial active - Subscription active - Check your payment method in Google Play - Canceled, with access until the period ends - Payment pending in Google Play - The full course, from A1 to B2. - All of A1 is free and stays free. Glosso Pro opens A2 and the rest of the course, plus downloads to study offline. - You have finished A1. A good moment to continue. - All of A1 is free and stays free. Glosso Pro opens %1$s and the rest of the course, plus downloads to study offline. - A2, B1, B2 and the whole course beyond A1 - Downloads to study offline - Practice and phonetic profile included - Monthly subscription - Loading the Google Play price… - Price unavailable. Check your connection. - Start %1$s free - Subscribe for %1$s - Subscribe - Restore purchases - Manage or cancel the subscription - Free trial active. It will become %1$s when it ends. - Free trial active. - Subscription active. - There is a problem with your payment. Update your method in Google Play to keep access. - Subscription canceled. You keep access until the paid period ends. - Payment pending. It will open when Google Play confirms it. - Payment is charged to your Google Play account. The subscription renews automatically %1$s until you cancel. - Payment is charged to your Google Play account. The subscription renews automatically until you cancel. - The %1$s free trial renews automatically; cancel before it ends so you are not charged. - Manage or cancel the subscription in Google Play > Subscriptions. - Close - %1$s %2$s - %1$s free, then %2$s - %1$s free - - %1$d day - %1$d days - - - %1$d week - %1$d weeks - - - %1$d month - %1$d months - - - %1$d year - %1$d years - - - per day - every %1$d days - - - per week - every %1$d weeks - - - per month - every %1$d months - - - per year - every %1$d years - + Written pronunciation Shows the IPA under each phrase and every sound, in practice and in your profile. Turn it off if it distracts you. Show written pronunciation (IPA) @@ -658,18 +586,9 @@ Pauses downloads on mobile data so nothing is spent behind your back. You can allow a single download from its notification. Downloading - - Glosso is in beta - Free until %1$s. - Free during the beta. - As a Beta Founder you will get Glosso Pro free forever. The Personalized plan is not included. - You are a Beta Founder: you have Glosso Pro free forever. The Personalized plan is not included. - Permanent access to Glosso Pro - Permanent access to Glosso Pro (Beta Founder) - Beta + A new version of Glosso is available. Updating is recommended, but everything keeps working. Dismiss - Plans are not for sale yet. Score %1$d out of 100 @@ -690,15 +609,15 @@ Account Change phone - Take your account and your Beta Founder status with you + Take your account with you to a new phone Change phone - Take your account and your Beta Founder status to a new phone. + Link your account to Google so it moves with you to a new phone. With your Google account Pick one of the Google accounts on this phone, no passwords. On the new phone, do the same with the same account. Linked to %1$s. On the new phone, sign in with this same account. Continue with Google We only keep the ID Google assigns to your account, not your email or name. No access to Gmail or Drive. - Done. Your account and your Beta Founder status now go with your Google account. + Done. Your account now goes with your Google account. There is no Google account on this phone. Add one in the system settings. Could not sign in with Google. Try again. This phone is already linked to another Google account. Sign in with that one. @@ -709,4 +628,10 @@ Delete my usage data Your usage data has been deleted. Could not delete it now. Try again with a connection. + Import from a file + Checking and installing the model… + Part of the pack is still missing. Pick both files: the model (.onnx) and phoneme_vocab.json. + A file is not part of the Glosso scoring model, or it is damaged. Pick the model (.onnx) and phoneme_vocab.json. + Could not read that file. Try again. + Usage statistics are off. To help improve the course, you can turn them on in Settings: they go only to our own servers (no ads, no third parties), linked to a random installation id. diff --git a/shared/src/commonMain/kotlin/com/glosso/app/AboutScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/AboutScreen.kt index acb476d..7baa2e4 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/AboutScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/AboutScreen.kt @@ -22,16 +22,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.glosso.app.billing.AccessPolicy +import androidx.compose.ui.platform.LocalUriHandler import com.glosso.app.designsystem.GlossoColors import com.glosso.app.designsystem.Space import com.glosso.app.product.ProductConfigRepository +import com.glosso.app.product.VersionNotice import com.glosso.app.resources.* import org.jetbrains.compose.resources.stringResource /** - * Settings > About. Version, the product's privacy stance and the full licence - * catalogue. Both the privacy policy and the attributions are rendered from + * Settings > About. Version, the voluntary support link, the product's privacy + * stance and the full licence catalogue. Both the privacy policy and the attributions are rendered from * assets bundled in the APK, so nothing points at the private repository. */ @Composable @@ -39,7 +40,6 @@ fun AboutScreen(onBack: () -> Unit) { var showLicenses by rememberSaveable { mutableStateOf(false) } var showPrivacy by rememberSaveable { mutableStateOf(false) } val config by ProductConfigRepository.config.collectAsState() - val account by ProductConfigRepository.account.collectAsState() val appConfig = LocalAppConfig.current if (showLicenses) { @@ -74,23 +74,18 @@ fun AboutScreen(onBack: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (config.isBeta || config.beta.active) { + if (VersionNotice.from(config, appConfig.versionCode).visible) { Spacer(Modifier.height(Space.x32)) - SectionLabel(stringResource(Res.string.about_beta_title)) - Spacer(Modifier.height(Space.x12)) - BetaBanner(config = config, account = account) - } else { - val notice = AccessPolicy.versionNotice(config, appConfig.versionCode) - if (notice.visible) { - Spacer(Modifier.height(Space.x32)) - Text( - text = stringResource(Res.string.update_notice), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = stringResource(Res.string.update_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + val uriHandler = LocalUriHandler.current + SupportGlossoSection(onOpen = { runCatching { uriHandler.openUri(it) } }) + Spacer(Modifier.height(Space.x32)) SectionLabel(stringResource(Res.string.about_privacy_title)) Spacer(Modifier.height(Space.x8)) @@ -118,7 +113,7 @@ fun AboutScreen(onBack: () -> Unit) { } @Composable -private fun AboutLink(text: String, onClick: () -> Unit) { +internal fun AboutLink(text: String, onClick: () -> Unit) { Text( text = text, style = MaterialTheme.typography.bodyLarge, diff --git a/shared/src/commonMain/kotlin/com/glosso/app/AccountSignIn.kt b/shared/src/commonMain/kotlin/com/glosso/app/AccountSignIn.kt index 9b03c4c..32463bb 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/AccountSignIn.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/AccountSignIn.kt @@ -50,5 +50,16 @@ interface AccountSignIn { suspend fun pickAccount(context: PlatformContext, nonce: String): AccountPick } -/** This platform's [AccountSignIn]. */ -expect val accountSignIn: AccountSignIn +/** + * The build's [AccountSignIn], installed by the platform shell at startup. The + * default is [UnavailableSignIn]: iOS has no sign-in yet, and the Android F-Droid + * build ships no Google libraries, so "Change phone" stays hidden there. + */ +var accountSignIn: AccountSignIn = UnavailableSignIn + +/** No sign-in in this build: the option stays hidden. */ +object UnavailableSignIn : AccountSignIn { + override val isConfigured: Boolean = false + + override suspend fun pickAccount(context: PlatformContext, nonce: String): AccountPick = AccountPick.Cancelled +} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/Analytics.kt b/shared/src/commonMain/kotlin/com/glosso/app/Analytics.kt index 4f95514..962d62c 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/Analytics.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/Analytics.kt @@ -35,8 +35,8 @@ import okio.Path * advertising id, no text the learner wrote and no audio; the server keeps only * the events and properties of its catalogue. * - * On by default with a notice in onboarding; the learner can turn it off in - * Settings, which drops the queue and tells the server (`X-Analytics: off`) not + * On by default with a notice in onboarding ([defaultEnabled]; the F-Droid build + * starts with it off); the learner can turn it off (or on) in Settings, which drops the queue and tells the server (`X-Analytics: off`) not * to record the score event either. Best-effort: nothing here can block the UI, * a take or a launch, and every failure is silent. */ @@ -55,6 +55,9 @@ object Analytics { private val json = Json { ignoreUnknownKeys = true } private val serializer = ListSerializer(ClientEvent.serializer()) + /** Whether a new install sends statistics before the learner chooses; set by the app before [init]. */ + var defaultEnabled: Boolean = true + private val _enabled = MutableStateFlow(true) val enabled: StateFlow = _enabled.asStateFlow() @@ -97,7 +100,7 @@ object Analytics { val loaded = keyValueStore(context, FILE) prefs = loaded platform = platformInfo(context) - _enabled.value = loaded.getBoolean(KEY_ENABLED, true) + _enabled.value = loaded.getBoolean(KEY_ENABLED, defaultEnabled) val files = appFiles(context) fileSystem = files.fileSystem queueFile = (files.filesDir / QUEUE_FILE).also { file -> @@ -296,6 +299,7 @@ object Analytics { contextRef = null queueLock.withLock { queue.clear() } _enabled.value = true + defaultEnabled = true sessionId = null foregroundSince = 0 backgroundSince = 0 diff --git a/shared/src/commonMain/kotlin/com/glosso/app/AppConfig.kt b/shared/src/commonMain/kotlin/com/glosso/app/AppConfig.kt index bda9665..b91890a 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/AppConfig.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/AppConfig.kt @@ -4,9 +4,8 @@ import androidx.compose.runtime.staticCompositionLocalOf /** * What the build decides and the shared code reads: the debug tools, the store - * capture mode, the experimental rhythm bonus, the version and the product the - * paywall manages. Android fills it from `BuildConfig`; iOS will from its bundle - * and build settings. Screens read [LocalAppConfig]; ViewModels get the fields + * capture mode, the experimental rhythm bonus and the version. Android fills it + * from `BuildConfig`; iOS will from its bundle and build settings. Screens read [LocalAppConfig]; ViewModels get the fields * they need in their constructor. */ data class AppConfig( @@ -18,8 +17,6 @@ data class AppConfig( val prosodyBonus: Boolean = false, val versionName: String = "", val versionCode: Int = 0, - /** The subscription the paywall manages when the store returned no offer. */ - val subscriptionProductId: String = "", /** * The usage statistics section of Settings. Off on iOS, where analytics stay * uninitialised until the privacy policy and the App Store forms cover it diff --git a/shared/src/commonMain/kotlin/com/glosso/app/AppServices.kt b/shared/src/commonMain/kotlin/com/glosso/app/AppServices.kt index da549d5..e2a381f 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/AppServices.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/AppServices.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import com.glosso.app.api.GlossoApi -import com.glosso.app.billing.SubscriptionRepository import com.glosso.app.download.Downloader import com.glosso.app.download.LocalDownloader import com.glosso.app.platform.Connectivity @@ -26,8 +25,6 @@ class AppServices( val downloader: Downloader, /** A new API client; every ViewModel keeps its own, as each built one before. */ val newApi: () -> GlossoApi, - /** The process's subscription repository (the store, or a debug fake). */ - val subscriptions: () -> SubscriptionRepository, ) /** The process's [AppServices], provided by [ProvideAppServices]. */ diff --git a/shared/src/commonMain/kotlin/com/glosso/app/BetaNotice.kt b/shared/src/commonMain/kotlin/com/glosso/app/BetaNotice.kt deleted file mode 100644 index 2b6b35b..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/BetaNotice.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.glosso.app - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.EmojiEvents -import androidx.compose.material.icons.outlined.SystemUpdate -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.glosso.app.api.AccountState -import com.glosso.app.api.ProductConfig -import com.glosso.app.billing.VersionNotice -import com.glosso.app.designsystem.Radius -import com.glosso.app.designsystem.Space -import com.glosso.app.product.formatBetaEndDate -import com.glosso.app.resources.* -import org.jetbrains.compose.resources.stringResource - -/** - * The honest beta card, shown while the open beta is on. It says three things - * and nothing more: the beta is open, the course is free until the date the - * server publishes (never a date written in the app), and what being a Beta - * Founder means. It never promises "always free": after the beta there will be - * plans, and the copy does not pretend otherwise. - * - * The date is formatted from `beta.endDate`; when the server has not published - * one yet, the copy says "during the beta" instead of inventing a date. - */ -@Composable -internal fun BetaBanner( - config: ProductConfig, - account: AccountState?, - modifier: Modifier = Modifier, -) { - if (!config.isBeta && !config.beta.active) return - - val endDate = formatBetaEndDate(config.beta.endDate) - val founderBenefit = config.beta.founderEnabled || account?.betaFounder == true - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(Radius.l), - border = cardBorder(), - modifier = modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.x20)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Outlined.EmojiEvents, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(Space.x12)) - Text( - text = stringResource(Res.string.beta_open_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(Modifier.height(Space.x8)) - Text( - text = if (endDate != null) { - stringResource(Res.string.beta_free_until, endDate) - } else { - stringResource(Res.string.beta_free_during) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (founderBenefit) { - Spacer(Modifier.height(Space.x8)) - Text( - text = if (account?.betaFounder == true) { - stringResource(Res.string.beta_founder_you_are) - } else { - stringResource(Res.string.beta_founder_note) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -/** - * The soft "a newer version is out" strip. It is a notice, not a gate: during - * the beta nothing blocks on a version, so it is dismissible and the app keeps - * working exactly as before. - */ -@Composable -internal fun UpdateNotice( - notice: VersionNotice, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - if (!notice.visible) return - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Space.gutter, vertical = Space.x8), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.SystemUpdate, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(Space.x12)) - Text( - text = stringResource(Res.string.update_notice), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - QuietTextButton(stringResource(Res.string.update_notice_dismiss), onClick = onDismiss) - } - } -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/FounderRecoveryScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/ChangePhoneScreen.kt similarity index 93% rename from shared/src/commonMain/kotlin/com/glosso/app/FounderRecoveryScreen.kt rename to shared/src/commonMain/kotlin/com/glosso/app/ChangePhoneScreen.kt index aef79d2..ff4e63f 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/FounderRecoveryScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/ChangePhoneScreen.kt @@ -40,19 +40,17 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource /** - * "Change phone": carry this device's account (and so the Beta Founder status - * and the lifetime PRO) to a new phone. + * "Change phone": carry this device's account to a new phone. * * The only way is Sign in with Google: the learner picks one of the Google * accounts already on the phone (no password, no form) and does the same on the * new phone. The server keeps only Google's opaque account id. */ @Composable -fun FounderRecoveryScreen(onBack: () -> Unit) { +fun ChangePhoneScreen(onBack: () -> Unit) { val context = currentPlatformContext() val connectivity = rememberConnectivity() val scope = rememberCoroutineScope() - val account by ProductConfigRepository.account.collectAsState() val googleEmail by GoogleAccountStore.email.collectAsState() var signingIn by remember { mutableStateOf(false) } @@ -74,14 +72,6 @@ fun FounderRecoveryScreen(onBack: () -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (account?.betaFounder == true) { - Spacer(Modifier.height(Space.x12)) - Text( - text = stringResource(Res.string.beta_founder_you_are), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } if (accountSignIn.isConfigured) { Spacer(Modifier.height(Space.x32)) diff --git a/shared/src/commonMain/kotlin/com/glosso/app/CourseOrder.kt b/shared/src/commonMain/kotlin/com/glosso/app/CourseOrder.kt new file mode 100644 index 0000000..6dc59dc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/glosso/app/CourseOrder.kt @@ -0,0 +1,89 @@ +package com.glosso.app + +import com.glosso.engine.model.LessonModel + +/** + * The order the course is taught in. The lesson list, Today and the "what is + * next" decision all read it, so they can never disagree on the next lesson. + */ +object CourseOrder { + + /** + * The theme order the course is taught in: the manifest order first, then any + * theme the manifest did not list, in the order it first appears. + */ + fun blockOrder(lessons: List, themeOrder: List = emptyList()): List { + val present = lessons.asSequence() + .map { it.theme } + .filter { it.isNotBlank() } + .distinct() + .toList() + val known = themeOrder.filter { it in present } + return known + present.filterNot { it in known } + } + + /** + * The order of the lessons inside one block: by level, then by the kind of + * base (a dialogue before a story before an article), then by id. + */ + val lessonOrder: Comparator = compareBy({ it.level }, { baseRank(it) }, { it.id }) + + private fun baseRank(lesson: LessonModel): Int = when { + lesson.dialogueId != null -> 0 + lesson.storyId != null -> 1 + lesson.articleId != null -> 2 + else -> 3 + } + + /** Every lesson in the order the course is taught: block by block, then [lessonOrder]. */ + fun teachingOrder(lessons: List, themeOrder: List = emptyList()): List { + val byTheme = lessons.groupBy { it.theme } + return blockOrder(lessons, themeOrder).flatMap { byTheme[it].orEmpty().sortedWith(lessonOrder) } + } + + /** The first lesson in teaching order: the one a new learner should meet. */ + fun firstLesson(lessons: List, themeOrder: List = emptyList()): LessonModel? = + teachingOrder(lessons, themeOrder).firstOrNull() + + /** + * The first lesson in teaching order the learner has not finished yet. Once + * every lesson is finished it falls back to the first one rather than to an + * empty card. + */ + fun nextLesson( + lessons: List, + themeOrder: List = emptyList(), + finishedLessonIds: Collection = emptyList(), + ): LessonModel? { + val ordered = teachingOrder(lessons, themeOrder) + return ordered.firstOrNull { it.id !in finishedLessonIds } ?: ordered.firstOrNull() + } +} + +/** + * What "Today" offers: the lesson to continue (when one is in progress and not + * finished) or the next unfinished lesson in teaching order. + */ +data class TodayPlan( + val lesson: LessonModel?, + /** True when [lesson] is the lesson already open, so the CTA says "Continue". */ + val resumable: Boolean, +) + +/** + * Resolves the Today target. [resumeLessonId] is only resumed while + * [resumeInProgress] is true (a lesson at SUMMARY is done); otherwise the first + * lesson in teaching order that is not in [finishedLessonIds] is offered. + */ +fun todayPlan( + lessons: List, + themeOrder: List, + resumeLessonId: String?, + resumeInProgress: Boolean, + finishedLessonIds: Collection = emptyList(), +): TodayPlan { + val inProgress = resumeLessonId?.let { id -> lessons.firstOrNull { it.id == id } } + val resumable = inProgress != null && resumeInProgress + val lesson = if (resumable) inProgress else CourseOrder.nextLesson(lessons, themeOrder, finishedLessonIds) + return TodayPlan(lesson = lesson, resumable = resumable) +} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/DeviceId.kt b/shared/src/commonMain/kotlin/com/glosso/app/DeviceId.kt index 30fdfb3..716f08b 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/DeviceId.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/DeviceId.kt @@ -34,9 +34,8 @@ object DeviceId { * * The first write is a synchronous `commit`, not `apply`: a process killed * right after the first launch must still find its id on the next one, or a - * fresh UUID would look like a new user (and a new Beta Founder) to the - * server. [init] holds a lock so two entry points cannot each generate - * one. + * fresh UUID would look like a new user to the server. [init] holds a lock + * so two entry points cannot each generate one. */ @OptIn(ExperimentalUuidApi::class) fun init(context: PlatformContext) = lock.withLock { diff --git a/shared/src/commonMain/kotlin/com/glosso/app/ExerciseSession.kt b/shared/src/commonMain/kotlin/com/glosso/app/ExerciseSession.kt index acea651..688b3c7 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/ExerciseSession.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/ExerciseSession.kt @@ -110,7 +110,6 @@ internal fun ExerciseRunner( modifier: Modifier = Modifier, onComplete: (() -> Unit)? = null, contentPadding: PaddingValues = PaddingValues(horizontal = Space.gutter), - footer: (@Composable () -> Unit)? = null, embedded: Boolean = false, /** * Names the base each exercise answers from, when the surrounding screen @@ -188,10 +187,6 @@ internal fun ExerciseRunner( }, ) } - footer?.let { - Spacer(Modifier.height(Space.x24)) - it() - } Spacer(Modifier.height(Space.x24)) } else { CompletionBeat(Modifier.fillMaxWidth().padding(vertical = Space.x72)) diff --git a/shared/src/commonMain/kotlin/com/glosso/app/ExercisesScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/ExercisesScreen.kt index 0898e6c..a5de718 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/ExercisesScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/ExercisesScreen.kt @@ -35,9 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus import com.glosso.app.designsystem.Motion import com.glosso.app.designsystem.Radius import com.glosso.app.designsystem.Sizes @@ -48,9 +45,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun ExercisesScreen( vm: ExercisesViewModel = exercisesViewModel(), - subscription: SubscriptionStatus = SubscriptionStatus.NONE, - productAccess: ProductAccess = ProductAccess.Unknown, - onShowPaywall: (String?) -> Unit = {}, onOpenSource: (ExerciseSource) -> Unit, onBack: () -> Unit, ) { @@ -58,9 +52,6 @@ fun ExercisesScreen( var theme by rememberSaveable { mutableStateOf(null) } val themes = remember(state.exercises) { state.exercises.map { it.theme }.distinct().sorted() } val visible = theme?.let { t -> state.exercises.filter { it.theme == t } } ?: state.exercises - // One gate for the screen: the controller decides per exercise, from the same - // CEFR frontier the lessons use. A free learner only ever runs A1. - val access = remember(subscription, productAccess) { AccessController(subscription, productAccess = productAccess) } Column(Modifier.fillMaxSize().statusBarsPadding()) { SubHeader(title = stringResource(Res.string.exercises_title), onBack = onBack) @@ -96,38 +87,13 @@ fun ExercisesScreen( } Spacer(Modifier.height(Space.x8)) } - val runnable = visible.filterNot { access.isLocked(it) } - val lockedSample = visible.firstOrNull { access.isLocked(it) } - if (runnable.isNotEmpty()) { + if (visible.isNotEmpty()) { ExerciseRunner( - exercises = runnable, + exercises = visible, modifier = Modifier.fillMaxWidth().weight(1f), sourceOf = vm::sourceOf, onOpenSource = onOpenSource, - footer = lockedSample?.let { locked -> - { - ProLockedCard( - title = stringResource(Res.string.pro_locked_title), - message = stringResource(Res.string.pro_locked_message), - onUnlock = { onShowPaywall(locked.level) }, - ) - } - }, ) - } else if (lockedSample != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(horizontal = Space.gutter), - ) { - ProLockedCard( - title = stringResource(Res.string.pro_locked_title), - message = stringResource(Res.string.pro_locked_message), - onUnlock = { onShowPaywall(lockedSample.level) }, - ) - } } } } diff --git a/shared/src/commonMain/kotlin/com/glosso/app/GlossoApp.kt b/shared/src/commonMain/kotlin/com/glosso/app/GlossoApp.kt index 3c0c39e..80bae35 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/GlossoApp.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/GlossoApp.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.outlined.DownloadForOffline import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.Quiz import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.outlined.WorkspacePremium import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -69,12 +68,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.ProFeature -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus -import com.glosso.app.billing.todayPlan import com.glosso.app.designsystem.GlossoColors import com.glosso.app.designsystem.LocalReduceMotion import com.glosso.app.designsystem.Motion @@ -82,6 +75,7 @@ import com.glosso.app.designsystem.Radius import com.glosso.app.designsystem.Sizes import com.glosso.app.designsystem.Space import com.glosso.app.product.ProductConfigRepository +import com.glosso.app.product.VersionNotice import com.glosso.app.resources.* import com.glosso.engine.model.LessonModel import com.glosso.engine.model.StoryModel @@ -208,10 +202,8 @@ fun GlossoApp() { val lessonVm: LessonViewModel = lessonViewModel() val storiesVm: StoriesViewModel = storiesViewModel() - val subscriptionVm: SubscriptionViewModel = subscriptionViewModel() val lessonState by lessonVm.state.collectAsState() val storiesState by storiesVm.state.collectAsState() - val subscription by subscriptionVm.status.collectAsState() // After a process death the ViewModels start empty while the shell comes // back saved: reopen the lesson or story it had open once the content is @@ -240,7 +232,6 @@ fun GlossoApp() { } } } - val productAccess by ProductConfigRepository.access.collectAsState() val productConfig by ProductConfigRepository.config.collectAsState() val config = LocalAppConfig.current val onboardingDone by OnboardingStore.done.collectAsState() @@ -256,10 +247,10 @@ fun GlossoApp() { val showOfflineModelNudge = LocalAppServices.current.onDevice.supported && !online && !scorer.present && !scorer.downloading && !scorer.preparing && !offlineHintDismissed - // A newer build may be recommended, but during the beta the notice only - // warns and is dismissible: nothing here blocks the learner. + // A newer build may be recommended, but the notice only warns and is + // dismissible: nothing here blocks the learner. val versionNotice = remember(productConfig) { - AccessPolicy.versionNotice(productConfig, config.versionCode) + VersionNotice.from(productConfig, config.versionCode) } var updateDismissed by rememberSaveable { mutableStateOf(false) } @@ -278,26 +269,14 @@ fun GlossoApp() { wasOnline = online } - // One paywall surface for the whole app, opened from any locked feature. The - // level the learner reached rides along so the copy names the moment (the end - // of A1) instead of an arbitrary mid-course wall. - var showPaywall by remember { mutableStateOf(false) } - var paywallNextLevel by remember { mutableStateOf(null) } - val openPaywall: (String?) -> Unit = { level -> - paywallNextLevel = level - showPaywall = true - } - - // Re-validate on every return to the foreground: the trial may have ended, the - // subscription may have been canceled or gone into grace while backgrounded, - // the beta may have ended, and the server flags may have changed. + // Re-validate on every return to the foreground: the connection, the server + // flags and the version policy may have changed while backgrounded. val lifecycleOwner = LocalLifecycleOwner.current val context = currentPlatformContext() val resumeScope = rememberCoroutineScope() DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - subscriptionVm.refresh() connectivity.refresh() resumeScope.launch { ProductConfigRepository.refresh() } resumeScope.launch { AccentScoringFlag.refresh(appApi) } @@ -341,27 +320,22 @@ fun GlossoApp() { } } - // Today's one tap: continue the lesson in progress, or open the first one in - // teaching order. It goes through the same AccessPolicy as the lesson list, so - // a locked lesson opens the paywall instead of slipping past the gate, and an - // in-progress lesson stays finishable. LessonsScreen renders the player, so - // the destination moves there first. + // Today's one tap: continue the lesson in progress, or open the next one in + // teaching order. LessonsScreen renders the player, so the destination moves + // there first. val progress by ProgressStore.state.collectAsState() - val today = remember(subscription, productAccess, lessonState.lessons, lessonState.themeOrder, lessonState.resumeLessonId, lessonState.resumeStep, progress.finishedLessons) { + val today = remember(lessonState.lessons, lessonState.themeOrder, lessonState.resumeLessonId, lessonState.resumeStep, progress.finishedLessons) { todayPlan( lessons = lessonState.lessons, themeOrder = lessonState.themeOrder, resumeLessonId = lessonState.resumeLessonId, resumeInProgress = lessonState.resumeStep != LessonStep.SUMMARY, - status = subscription, - productAccess = productAccess, finishedLessonIds = progress.finishedLessons, ) } val startTodayLesson: () -> Unit = { shell = shell.openLessons() when { - today.locked -> openPaywall(today.lesson?.level) today.resumable -> lessonVm.resume() else -> today.lesson?.let { lessonVm.openLesson(it) } } @@ -369,16 +343,8 @@ fun GlossoApp() { val browseLessons: () -> Unit = { shell = shell.openLessons() } - // One gate for the shell: the Today CTAs ask the same controller the lesson - // list does, so a Pro action can never slip past the wall. - val access = remember(subscription, productAccess) { AccessController(subscription, productAccess = productAccess) } - val profileLocked = access.isLocked(ProFeature.PHONETIC_PROFILE) val openTodayReview: () -> Unit = { - if (access.isLocked(ProFeature.PERSONALIZED_PRACTICE)) { - openPaywall(null) - } else { - shell = shell.copy(destination = GlossoDestination.LIBRARY, route = LibraryRoute.PRACTICE) - } + shell = shell.copy(destination = GlossoDestination.LIBRARY, route = LibraryRoute.PRACTICE) } val openTodaySettings: () -> Unit = { shell = shell.copy(destination = GlossoDestination.LIBRARY, route = LibraryRoute.SETTINGS) @@ -425,27 +391,17 @@ fun GlossoApp() { loading = lessonState.loading && lessonState.lessons.isEmpty(), hasLessons = lessonState.lessons.isNotEmpty(), offline = !online, - soundsLocked = profileLocked, onStartLesson = startTodayLesson, onBrowseLessons = browseLessons, onReviewSounds = openTodayReview, - onUnlockSounds = { openPaywall(null) }, onSettings = openTodaySettings, error = lessonState.error, onRetry = { lessonVm.refresh() }, ) - GlossoDestination.LESSONS -> LessonsScreen( - vm = lessonVm, - subscription = subscription, - productAccess = productAccess, - onShowPaywall = openPaywall, - ) + GlossoDestination.LESSONS -> LessonsScreen(vm = lessonVm) GlossoDestination.YOU -> YouScreen( - subscription = subscription, - productAccess = productAccess, - onShowPaywall = openPaywall, onPractice = { shell = shell.copy(destination = GlossoDestination.LIBRARY, route = LibraryRoute.PRACTICE) }, @@ -455,9 +411,6 @@ fun GlossoApp() { route = route, storiesVm = storiesVm, lessonVm = lessonVm, - subscription = subscription, - productAccess = productAccess, - onShowPaywall = openPaywall, onOpen = { shell = shell.copy(route = it) }, onOpenLesson = { lesson -> shell = shell.openLessons() @@ -502,16 +455,6 @@ fun GlossoApp() { AchievementOverlay(modifier = Modifier.align(Alignment.TopCenter)) } - if (showPaywall) { - TrackScreen("paywall") - PaywallSheet( - vm = subscriptionVm, - productAccess = productAccess, - nextLevel = paywallNextLevel, - onDismiss = { showPaywall = false }, - ) - } - // First run only: the short welcome, over the course, until it is dismissed. // The guard keeps a tap on its empty areas from reaching the app behind. if (!onboardingDone) { @@ -526,24 +469,10 @@ private fun LibraryRoutes( route: LibraryRoute?, storiesVm: StoriesViewModel, lessonVm: LessonViewModel, - subscription: SubscriptionStatus, - productAccess: ProductAccess, - onShowPaywall: (String?) -> Unit, onOpen: (LibraryRoute) -> Unit, onOpenLesson: (LessonModel) -> Unit, onBack: () -> Unit, ) { - // The exercise library names and opens the base of every task it shows. The - // jump asks the same controller the lesson list does, in-progress lesson - // included: a Pro lesson seen as unlocked in Lessons must not paywall here. - val lessonState by lessonVm.state.collectAsState() - val access = remember(subscription, productAccess, lessonState.resumeLessonId) { - AccessController( - status = subscription, - inProgressLessonId = lessonState.resumeLessonId, - productAccess = productAccess, - ) - } AnimatedContent( targetState = route, transitionSpec = { @@ -559,53 +488,24 @@ private fun LibraryRoutes( label = "libraryRoute", ) { r -> when (r) { - null -> LibraryScreen( - onOpen = onOpen, - subscription = subscription, - productAccess = productAccess, - onShowPaywall = onShowPaywall, - ) + null -> LibraryScreen(onOpen = onOpen) - LibraryRoute.STORIES -> StoriesScreen( - vm = storiesVm, - subscription = subscription, - productAccess = productAccess, - onShowPaywall = onShowPaywall, - onBack = onBack, - ) + LibraryRoute.STORIES -> StoriesScreen(vm = storiesVm, onBack = onBack) LibraryRoute.EXERCISES -> ExercisesScreen( - subscription = subscription, - productAccess = productAccess, - onShowPaywall = onShowPaywall, onOpenSource = { source -> when (source) { is ExerciseSource.Story -> { - if (access.isLocked(source.story)) { - onShowPaywall(source.story.level) - } else { - storiesVm.openStory(source.story) - onOpen(LibraryRoute.STORIES) - } - } - is ExerciseSource.Lesson -> { - if (access.isLocked(source.lesson)) { - onShowPaywall(source.lesson.level) - } else { - onOpenLesson(source.lesson) - } + storiesVm.openStory(source.story) + onOpen(LibraryRoute.STORIES) } + is ExerciseSource.Lesson -> onOpenLesson(source.lesson) } }, onBack = onBack, ) - LibraryRoute.PRACTICE -> PracticeScreen( - subscription = subscription, - productAccess = productAccess, - onShowPaywall = onShowPaywall, - onBack = onBack, - ) + LibraryRoute.PRACTICE -> PracticeScreen(onBack = onBack) LibraryRoute.OFFLINE -> OfflineScreen(vm = lessonVm, onBack = onBack) @@ -615,12 +515,7 @@ private fun LibraryRoutes( } @Composable -private fun LibraryScreen( - onOpen: (LibraryRoute) -> Unit, - subscription: SubscriptionStatus, - productAccess: ProductAccess, - onShowPaywall: (String?) -> Unit, -) { +private fun LibraryScreen(onOpen: (LibraryRoute) -> Unit) { val scroll = rememberScrollState() val collapse = rememberCollapse(scroll) Column(Modifier.fillMaxSize().statusBarsPadding()) { @@ -643,18 +538,6 @@ private fun LibraryScreen( onClick = { onOpen(LibraryRoute.PRACTICE) }, ) Spacer(Modifier.height(Space.x12)) - // While the beta is open nothing is for sale, so the Pro row is not - // shown: the app does not advertise a plan it cannot sell. Once the - // beta ends (and a real price is published) the row returns. - if (!productAccess.betaFree) { - HubRow( - icon = Icons.Outlined.WorkspacePremium, - title = stringResource(Res.string.library_pro_title), - subtitle = subscriptionSummary(subscription, productAccess), - onClick = { onShowPaywall(null) }, - ) - Hairline() - } HubRow( icon = Icons.Outlined.AutoStories, title = stringResource(Res.string.library_stories_title), @@ -739,32 +622,10 @@ private fun FeaturedTile( } } -/** The Library row's honest one-liner about the current access or subscription. */ -@Composable -private fun subscriptionSummary(status: SubscriptionStatus, productAccess: ProductAccess): String = when { - productAccess.lifetimeGrant != null -> { - if (productAccess.betaFounder) { - stringResource(Res.string.entitlement_lifetime_founder) - } else { - stringResource(Res.string.entitlement_lifetime_pro) - } - } - status == SubscriptionStatus.NONE -> stringResource(Res.string.pro_summary_none) - status == SubscriptionStatus.TRIAL -> stringResource(Res.string.pro_summary_trial) - status == SubscriptionStatus.ACTIVE -> stringResource(Res.string.pro_summary_active) - status == SubscriptionStatus.GRACE -> stringResource(Res.string.pro_summary_grace) - status == SubscriptionStatus.CANCELED -> stringResource(Res.string.pro_summary_canceled) - status == SubscriptionStatus.PENDING -> stringResource(Res.string.pro_summary_pending) - else -> stringResource(Res.string.pro_summary_none) -} - @OptIn(ExperimentalFoundationApi::class) @Composable internal fun PracticeScreen( vm: PracticeViewModel = practiceViewModel(), - subscription: SubscriptionStatus = SubscriptionStatus.NONE, - productAccess: ProductAccess = ProductAccess.Unknown, - onShowPaywall: (String?) -> Unit = {}, onBack: () -> Unit, ) { val config = LocalAppConfig.current @@ -773,12 +634,10 @@ internal fun PracticeScreen( val level = vm.level.collectAsState() val playing by vm.playing.collectAsState() val total = state.drills.size.coerceAtLeast(1) - // The same gate the lessons use: the view model is told the entitlement and - // only plans from the profile when it is Pro, and each drill is checked again - // here so a free learner can never open an A2+ one. - val access = remember(subscription, productAccess) { AccessController(subscription, productAccess = productAccess) } - LaunchedEffect(subscription, productAccess) { vm.setAccess(subscription, productAccess) } - val personalized = access.entitled && state.source == PracticeSource.PROFILE + // Re-plan on every entry, so a profile recorded since the last visit turns + // the general bank into the personalised plan. + LaunchedEffect(Unit) { vm.load() } + val personalized = state.source == PracticeSource.PROFILE Column(Modifier.fillMaxSize().statusBarsPadding()) { SubHeader( @@ -822,20 +681,6 @@ internal fun PracticeScreen( } when { - // A Pro drill that still reaches the screen: gate it here, item by - // item, instead of letting its record control start a paid take. - state.drill?.let { access.isLocked(it) } == true -> { - val locked = state.drill!! - Column(Modifier.fillMaxSize().padding(horizontal = Space.gutter)) { - Spacer(Modifier.height(Space.x24)) - ProLockedCard( - title = stringResource(Res.string.pro_locked_title), - message = stringResource(Res.string.pro_locked_message), - onUnlock = { onShowPaywall(locked.level) }, - ) - } - } - state.loading && state.drills.isEmpty() -> LoadingState(Modifier.weight(1f)) diff --git a/shared/src/commonMain/kotlin/com/glosso/app/GlossoComponents.kt b/shared/src/commonMain/kotlin/com/glosso/app/GlossoComponents.kt index e9bbd29..9da9dbd 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/GlossoComponents.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/GlossoComponents.kt @@ -50,7 +50,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.CloudOff -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -944,50 +943,6 @@ internal fun OfflineBanner( } } -/** - * The one shape a locked Pro surface takes: a lock, the plain reason and the CTA - * that opens the paywall. Every gated surface uses it, so the wall reads the same - * wherever the learner meets it and no screen invents its own. - */ -@Composable -internal fun ProLockedCard( - title: String, - message: String, - onUnlock: () -> Unit, - modifier: Modifier = Modifier, - cta: String = stringResource(Res.string.lessons_unlock_with_pro), -) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(Radius.l), - border = cardBorder(), - modifier = modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.x20), horizontalAlignment = Alignment.Start) { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(26.dp), - ) - Spacer(Modifier.height(Space.x12)) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(Space.x4)) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(Space.x20)) - PrimaryButton(text = cta, onClick = onUnlock) - } - } -} - /** * A short reward when something real happens: an achievement unlocks or a sound * is mastered. It drops in with a spring, the seal stamps itself, and it leaves diff --git a/shared/src/commonMain/kotlin/com/glosso/app/LessonViewModel.kt b/shared/src/commonMain/kotlin/com/glosso/app/LessonViewModel.kt index b498739..dbbefcb 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/LessonViewModel.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/LessonViewModel.kt @@ -74,11 +74,7 @@ data class LessonState( val levels: List = emptyList(), /** Theme display names, straight from the manifest. */ val themeLabels: Map = emptyMap(), - /** - * The teaching order of the theme blocks, straight from the manifest. The - * free frontier is the first block of this order, never the alphabetically - * first theme, so the lesson list and the paywall agree on where "free" ends. - */ + /** The teaching order of the theme blocks, straight from the manifest. */ val themeOrder: List = emptyList(), val active: LessonModel? = null, val step: LessonStep = LessonStep.BASE, diff --git a/shared/src/commonMain/kotlin/com/glosso/app/LessonsScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/LessonsScreen.kt index 725e3db..0ecb82a 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/LessonsScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/LessonsScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.CloudDone import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.DownloadForOffline -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -95,17 +94,11 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.LessonAccess -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus import com.glosso.app.designsystem.GlossoColors import com.glosso.app.designsystem.GlossoType import com.glosso.app.designsystem.LocalReduceMotion @@ -125,23 +118,10 @@ import org.jetbrains.compose.resources.stringResource @Composable fun LessonsScreen( vm: LessonViewModel = lessonViewModel(), - subscription: SubscriptionStatus = SubscriptionStatus.NONE, - productAccess: ProductAccess = ProductAccess.Unknown, - onShowPaywall: (String?) -> Unit = {}, ) { val state by vm.state.collectAsState() val progress by ProgressStore.state.collectAsState() val offline by OfflineStore.state.collectAsState() - // The one object the screen asks about access: it fixes the free frontier - // from the teaching order and answers per lesson, so no row re-derives it. - val access = remember(subscription, productAccess, state.resumeLessonId) { - AccessController( - status = subscription, - inProgressLessonId = state.resumeLessonId, - productAccess = productAccess, - ) - } - val entitled = access.entitled val active = state.active var showLevelPicker by remember { mutableStateOf(false) } var downloadSheet by remember { mutableStateOf(null) } @@ -222,27 +202,23 @@ fun LessonsScreen( ) else -> { - // The resume target and its access decision live here, once, + // The resume target lives here, once, // shared by the big hero and the compact bar so the two can // never disagree on what "Continue" does. val today = state.lessons.firstOrNull { it.id == state.resumeLessonId } - ?: AccessPolicy.nextLesson(state.lessons, state.themeOrder, progress.finishedLessons) + ?: CourseOrder.nextLesson(state.lessons, state.themeOrder, progress.finishedLessons) val resumeStep = state.resumeStep .takeIf { today != null && today.id == state.resumeLessonId && it != LessonStep.SUMMARY } // "Start here" is only true of the very first lesson: once the // learner has moved past it, the hero names what comes next. - val first = AccessPolicy.firstLesson(state.lessons, state.themeOrder) + val first = CourseOrder.firstLesson(state.lessons, state.themeOrder) val heroKicker = when { resumeStep != null -> Res.string.lessons_continue_kicker today != null && today.id != first?.id -> Res.string.lessons_next_kicker else -> Res.string.lessons_start_kicker } - val heroLocked = today != null && access.isLocked(today) - val heroFree = today != null && !entitled && - access.accessOf(today) == LessonAccess.FREE val openToday: () -> Unit = { when { - heroLocked -> onShowPaywall(today.level) resumeStep != null -> vm.resume() else -> today?.let { vm.openLesson(it) } } @@ -273,7 +249,6 @@ fun LessonsScreen( lesson = today, kicker = heroKicker, step = resumeStep, - locked = heroLocked, onOpen = openToday, ) } @@ -293,16 +268,14 @@ fun LessonsScreen( lesson = today, kicker = heroKicker, step = resumeStep, - locked = heroLocked, - free = heroFree, onOpen = openToday, ) } } val byTheme = state.lessons.groupBy { it.theme } - AccessPolicy.blockOrder(state.lessons, state.themeOrder) + CourseOrder.blockOrder(state.lessons, state.themeOrder) .forEach { theme -> - val lessons = byTheme[theme].orEmpty().sortedWith(AccessPolicy.lessonOrder) + val lessons = byTheme[theme].orEmpty().sortedWith(CourseOrder.lessonOrder) item(key = "section:$theme") { UnitHeader( title = themeLabel(theme, state.themeLabels), @@ -314,16 +287,12 @@ fun LessonsScreen( val finished = lesson.id in progress.finishedLessons val inProgressStep = state.resumeStep .takeIf { lesson.id == state.resumeLessonId && !finished } - val locked = access.isLocked(lesson) - val free = !entitled && access.accessOf(lesson) == LessonAccess.FREE - val canDownload = access.canDownload(lesson) StaggeredAppear(index = index) { PathRow( first = index == 0, last = index == lessons.lastIndex, finished = finished, stepFraction = inProgressStep?.let { stepFraction(lessonSteps(lesson), it) }, - locked = locked, ) { LessonRow( lesson = lesson, @@ -331,15 +300,8 @@ fun LessonsScreen( finished = finished, step = inProgressStep, offline = offline, - locked = locked, - showFreeBadge = free, - onClick = { - if (locked) onShowPaywall(lesson.level) else vm.openLesson(lesson) - }, - onDownload = { - if (canDownload) downloadSheet = lesson - else onShowPaywall(lesson.level) - }, + onClick = { vm.openLesson(lesson) }, + onDownload = { downloadSheet = lesson }, ) } } @@ -378,7 +340,6 @@ private fun ContinueBar( lesson: LessonModel, kicker: StringResource, step: LessonStep?, - locked: Boolean, onOpen: () -> Unit, modifier: Modifier = Modifier, ) { @@ -426,11 +387,7 @@ private fun ContinueBar( ) { Text( text = stringResource( - when { - locked -> Res.string.lessons_unlock_with_pro - step != null -> Res.string.lessons_continue - else -> Res.string.lessons_start - }, + if (step != null) Res.string.lessons_continue else Res.string.lessons_start, ), style = MaterialTheme.typography.labelLarge, ) @@ -451,20 +408,11 @@ private fun TodayHeroCard( lesson: LessonModel, kicker: StringResource, step: LessonStep?, - locked: Boolean, - free: Boolean, onOpen: () -> Unit, ) { LessonHeroSurface( kicker = stringResource(kicker), - tags = { - if (locked) { - GlossoTag("Pro") - } else { - GlossoTag(baseLabel(lesson)) - if (free) FreeTag() - } - }, + tags = { GlossoTag(baseLabel(lesson)) }, ) { LessonHeroTitle(lesson.title, lesson.objective) if (step != null) { @@ -473,13 +421,7 @@ private fun TodayHeroCard( } Spacer(Modifier.height(Space.x24)) PrimaryButton( - text = stringResource( - when { - locked -> Res.string.lessons_unlock_with_pro - step != null -> Res.string.lessons_continue - else -> Res.string.lessons_start - }, - ), + text = stringResource(if (step != null) Res.string.lessons_continue else Res.string.lessons_start), onClick = onOpen, ) } @@ -508,8 +450,8 @@ private fun UnitHeader(title: String, done: Int, total: Int) { /** * One stop on the course path: a rail runs down the left edge through every * lesson of a unit, and each lesson sits on a node that says its state - a - * filled brass seal when done, a ring filling up while in progress, a lock - * past the frontier, an open circle still to do. + * filled brass seal when done, a ring filling up while in progress, an open + * circle still to do. */ @Composable private fun PathRow( @@ -517,7 +459,6 @@ private fun PathRow( last: Boolean, finished: Boolean, stepFraction: Float?, - locked: Boolean, content: @Composable () -> Unit, ) { val rail = MaterialTheme.colorScheme.outline @@ -543,7 +484,7 @@ private fun PathRow( .size(28.dp), contentAlignment = Alignment.Center, ) { - PathNode(finished = finished, stepFraction = stepFraction, locked = locked) + PathNode(finished = finished, stepFraction = stepFraction) } } Box(Modifier.weight(1f)) { content() } @@ -551,7 +492,7 @@ private fun PathRow( } @Composable -private fun PathNode(finished: Boolean, stepFraction: Float?, locked: Boolean) { +private fun PathNode(finished: Boolean, stepFraction: Float?) { val bg = MaterialTheme.colorScheme.background val outline = MaterialTheme.colorScheme.outline val primary = MaterialTheme.colorScheme.primary @@ -568,18 +509,6 @@ private fun PathNode(finished: Boolean, stepFraction: Float?, locked: Boolean) { ) } - locked -> Box( - Modifier.size(28.dp).clip(CircleShape).background(bg).border(1.dp, outline, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.Lock, - contentDescription = stringResource(Res.string.lessons_locked), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - } - stepFraction != null -> { val fraction = stepFraction Canvas(Modifier.size(28.dp)) { @@ -664,8 +593,6 @@ private fun LessonRow( finished: Boolean, step: LessonStep?, offline: OfflineState, - locked: Boolean, - showFreeBadge: Boolean, onClick: () -> Unit, onDownload: () -> Unit, ) { @@ -680,10 +607,6 @@ private fun LessonRow( Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { BaseTag(baseLabel(lesson)) - if (showFreeBadge) { - Spacer(Modifier.width(Space.x8)) - FreeTag() - } Spacer(Modifier.width(Space.x12)) Text( // The unit header above already names the theme. @@ -699,8 +622,7 @@ private fun LessonRow( Text( text = lesson.title, style = MaterialTheme.typography.titleLarge, - color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(Space.x8)) @@ -757,30 +679,12 @@ private fun BaseTag(label: String) { GlossoTag(label) } -/** Marks a lesson inside the free block, so the frontier is visible. */ -@Composable -private fun FreeTag() { - Surface( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - shape = RoundedCornerShape(Radius.full), - ) { - Text( - text = stringResource(Res.string.lesson_free_badge), - modifier = Modifier.padding(horizontal = Space.x12, vertical = Space.x4), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = GlossoColors.accentText, - ) - } -} - /** * Swallows every pointer event that reaches this surface. Used as a guard layer * between an overlay (the lesson player, the first-run welcome) and the screen * behind it: without it, a tap or drag on an empty area of the overlay - the * header, a gap between lines, the padding around the action bar - falls through - * to whatever row sits behind, opening another lesson or the paywall by - * accident. Kept as a sibling under the overlay so the overlay's own buttons and + * to whatever row sits behind, opening another lesson by accident. Kept as a sibling under the overlay so the overlay's own buttons and * scrolling are never starved of events. */ internal fun Modifier.consumePointerEvents(): Modifier = pointerInput(Unit) { diff --git a/shared/src/commonMain/kotlin/com/glosso/app/OfflineModelUi.kt b/shared/src/commonMain/kotlin/com/glosso/app/OfflineModelUi.kt index 52ae633..ab0ddb4 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/OfflineModelUi.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/OfflineModelUi.kt @@ -31,6 +31,7 @@ import com.glosso.app.designsystem.Sizes import com.glosso.app.designsystem.Space import com.glosso.app.download.DownloadItemState import com.glosso.app.download.DownloadStatus +import com.glosso.app.platform.rememberFilePicker import com.glosso.app.resources.* import org.jetbrains.compose.resources.stringResource @@ -91,6 +92,8 @@ internal fun OnDevicePackControl( onRemove: (() -> Unit)?, ) { var confirmRemove by remember { mutableStateOf(false) } + val onDevice = LocalAppServices.current.onDevice + val pickFiles = rememberFilePicker { files -> onDevice.importPack(files) } if (confirmRemove && onRemove != null) { AlertDialog( onDismissRequest = { confirmRemove = false }, @@ -131,6 +134,12 @@ internal fun OnDevicePackControl( } } + state.importing -> Text( + text = stringResource(Res.string.import_model_progress), + style = MaterialTheme.typography.bodyMedium, + color = GlossoColors.accentText, + ) + state.preparing -> Text( text = stringResource(Res.string.settings_preparing), style = MaterialTheme.typography.bodyMedium, @@ -174,6 +183,18 @@ internal fun OnDevicePackControl( style = MaterialTheme.typography.labelLarge, ) } + // The same pack from files fetched elsewhere, for a phone that cannot + // or should not download it; verified like a download. + if (pickFiles != null) { + Spacer(Modifier.height(Space.x4)) + TextButton( + colors = accentTextButtonColors(), + onClick = pickFiles, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.import_model_cta), style = MaterialTheme.typography.labelLarge) + } + } } } diff --git a/shared/src/commonMain/kotlin/com/glosso/app/OfflineScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/OfflineScreen.kt index 46ca47a..c2ae099 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/OfflineScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/OfflineScreen.kt @@ -44,9 +44,7 @@ import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource /** * The offline drawer: what is on the device, how much room it takes, and the one - * place to free it. Downloads follow the content frontier - free lessons for a - * free learner, any lesson with a subscription - so this screen never assumes the - * learner is Pro. The gate lives on each lesson's download control. + * place to free it. Each lesson is downloaded from its own download control. */ @Composable fun OfflineScreen( diff --git a/shared/src/commonMain/kotlin/com/glosso/app/OnDeviceScoring.kt b/shared/src/commonMain/kotlin/com/glosso/app/OnDeviceScoring.kt index 4674751..2efa516 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/OnDeviceScoring.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/OnDeviceScoring.kt @@ -2,6 +2,7 @@ package com.glosso.app import com.glosso.app.api.GlossoApi import com.glosso.app.api.ScoreRequest +import com.glosso.app.platform.PickedFile import com.glosso.engine.model.PronunciationFeedback import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,6 +20,8 @@ data class OnDeviceScorerState( val progress: Float = 0f, /** True while the model is being loaded into memory (heavy, once). */ val preparing: Boolean = false, + /** True while picked files are copied and verified ([OnDeviceScoring.importPack]). */ + val importing: Boolean = false, val ready: Boolean = false, val error: String? = null, ) @@ -59,6 +62,13 @@ interface OnDeviceScoring { /** Downloads the model pack from the app's own host. */ suspend fun download() + /** + * Installs the pack from files the learner picked instead of downloading it, + * verified against the same digests a download is. Runs in the background; + * the outcome shows in [state] (`present`, or a localized `error`). + */ + fun importPack(files: List) = Unit + /** Removes the pack and unloads the model, freeing the memory it held. */ suspend fun delete() diff --git a/shared/src/commonMain/kotlin/com/glosso/app/OnboardingScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/OnboardingScreen.kt index 2599296..b254225 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/OnboardingScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/OnboardingScreen.kt @@ -65,8 +65,6 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.glosso.app.api.AccountState -import com.glosso.app.api.ProductConfig import com.glosso.app.designsystem.GlossoColors import com.glosso.app.designsystem.LocalReduceMotion import com.glosso.app.designsystem.Motion @@ -74,7 +72,6 @@ import com.glosso.app.designsystem.Radius import com.glosso.app.designsystem.Sizes import com.glosso.app.designsystem.Space import com.glosso.app.download.DownloadItemState -import com.glosso.app.product.ProductConfigRepository import com.glosso.app.resources.* import com.glosso.engine.model.LanguagePacks import com.glosso.engine.model.displayPhonetic @@ -95,8 +92,6 @@ fun OnboardingScreen(onFinish: () -> Unit) { val attempt by vm.attempt.collectAsState() val playing by vm.playing.collectAsState() val level = vm.level.collectAsState() - val productConfig by ProductConfigRepository.config.collectAsState() - val account by ProductConfigRepository.account.collectAsState() val scorer by scoringVm.state.collectAsState() val downloads by LocalDownloader.current.state.collectAsState() val online by rememberConnectivity().online.collectAsState() @@ -220,8 +215,6 @@ fun OnboardingScreen(onFinish: () -> Unit) { when (page) { 0 -> PromisePage( targetLanguage = targetLanguage, - config = productConfig, - account = account, onTargetLanguage = { code -> LanguagePreferences.set(code) OfferedLanguages.orNull(code)?.let { VoicePreferences.onLanguageChanged(it) } @@ -262,8 +255,6 @@ private const val LAST_PAGE = PAGE_COUNT - 1 @Composable private fun PromisePage( targetLanguage: String, - config: ProductConfig, - account: AccountState?, onTargetLanguage: (String) -> Unit, ) { val scroll = rememberScrollState() @@ -290,20 +281,21 @@ private fun PromisePage( style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // What the learner studies comes before the beta note: the next pages - // already speak in the chosen accent, so the choice must not sit below the fold. + // The next pages already speak in the chosen accent, so the choice must + // not sit below the fold. Spacer(Modifier.height(Space.x24)) TargetLanguageChoice(selected = targetLanguage, onSelect = onTargetLanguage) AccentChoice(targetLanguage) - Spacer(Modifier.height(Space.x32)) - BetaBanner(config = config, account = account) if (rememberPlatformActions().appLocaleSupported) { Spacer(Modifier.height(Space.x32)) AppLanguageChoice() } Spacer(Modifier.height(Space.x32)) Text( - text = stringResource(Res.string.onboarding_analytics_notice), + text = stringResource( + if (Analytics.enabled.collectAsState().value) Res.string.onboarding_analytics_notice + else Res.string.onboarding_analytics_notice_off, + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/shared/src/commonMain/kotlin/com/glosso/app/PaywallSheet.kt b/shared/src/commonMain/kotlin/com/glosso/app/PaywallSheet.kt deleted file mode 100644 index 561a329..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/PaywallSheet.kt +++ /dev/null @@ -1,462 +0,0 @@ -package com.glosso.app - -import com.glosso.app.platform.ScreenshotProtection -import com.glosso.app.platform.currentPlatformActivity -import com.glosso.app.platform.rememberPlatformActions -import com.glosso.app.platform.PlatformBackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionPlan -import com.glosso.app.billing.SubscriptionStatus -import com.glosso.app.billing.offerLine -import com.glosso.app.billing.periodFrequency -import com.glosso.app.billing.trialLabel -import com.glosso.app.designsystem.Elevation -import com.glosso.app.designsystem.GlossoColors -import com.glosso.app.designsystem.Motion -import com.glosso.app.designsystem.Radius -import com.glosso.app.designsystem.Sizes -import com.glosso.app.designsystem.Space -import com.glosso.app.resources.* -import org.jetbrains.compose.resources.stringResource -import kotlin.math.roundToInt - -/** - * The paywall. It shows exactly what Play returned (trial length and price), one - * primary action, a restore control, a way to manage or cancel in Google Play and - * the required legal note. It never invents a price or a trial length. - * - * Drawn as an in-app overlay, not a dialog: a visible scrim to tap, a grabber to - * drag down, an explicit close button and a back press that always closes the - * sheet first, never the lesson or the app underneath. - */ -@Composable -fun PaywallSheet( - vm: SubscriptionViewModel = subscriptionViewModel(), - /** What the server says this device may buy; nothing is sold while it is off. */ - productAccess: ProductAccess = ProductAccess.Unknown, - /** The paid level the learner reached, when the wall is the CEFR frontier. */ - nextLevel: String? = null, - onDismiss: () -> Unit, -) { - val status by vm.status.collectAsState() - val plan by vm.plan.collectAsState() - val loading by vm.loading.collectAsState() - val error by vm.error.collectAsState() - val activity = currentPlatformActivity() - val actions = rememberPlatformActions() - val config = LocalAppConfig.current - val entitled = AccessPolicy.isEntitled(status, productAccess) - - // Usage statistics: which wall was seen (a CEFR frontier or Pro in general). - val source = if (nextLevel != null) "level" else "general" - LaunchedEffect(Unit) { Analytics.track("paywall_viewed", mapOf("source" to source)) } - - // Back closes the paywall first, never the lesson or the app behind it. - PlatformBackHandler(enabled = true) { onDismiss() } - - val dismissPx = with(LocalDensity.current) { 140.dp.toPx() } - var dragOffset by remember { mutableFloatStateOf(0f) } - var dragging by remember { mutableStateOf(false) } - val renderOffset by animateFloatAsState( - targetValue = if (dragging) dragOffset else 0f, - animationSpec = tween(if (dragging) 0 else 220, easing = Motion.standard), - label = "paywallOffset", - ) - // The paywall shows the offer, price and trial. Mark the window secure only - // while it is on screen, so its contents cannot be captured by a screenshot - // or shown in the recents thumbnail; the practice screens stay capturable. - ScreenshotProtection() - - Box(Modifier.fillMaxSize()) { - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClickLabel = stringResource(Res.string.close), - onClick = onDismiss, - ), - ) - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = Radius.xl, topEnd = Radius.xl), - shadowElevation = Elevation.sheet, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .fillMaxHeight(0.9f) - .offset { IntOffset(0, renderOffset.roundToInt().coerceAtLeast(0)) }, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Space.gutter) - .navigationBarsPadding() - .padding(bottom = Space.x24), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { dragging = true }, - onVerticalDrag = { change, amount -> - change.consume() - dragOffset = (dragOffset + amount).coerceAtLeast(0f) - }, - onDragEnd = { - if (dragOffset > dismissPx) { - onDismiss() - } else { - dragOffset = 0f - dragging = false - } - }, - onDragCancel = { - dragOffset = 0f - dragging = false - }, - ) - }, - contentAlignment = Alignment.Center, - ) { - Box( - Modifier - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(Radius.full)) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - SectionLabel(stringResource(Res.string.library_pro_title)) - Spacer(Modifier.weight(1f)) - IconButton(onClick = onDismiss) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(Res.string.paywall_close), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.height(Space.x12)) - Text( - text = if (nextLevel != null) { - stringResource(Res.string.paywall_headline_next_level) - } else { - stringResource(Res.string.paywall_headline) - }, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(Space.x8)) - Text( - text = if (nextLevel != null) { - stringResource(Res.string.paywall_body_next_level, nextLevel) - } else { - stringResource(Res.string.paywall_body) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - PaywallOffer( - plan = plan, - loading = loading, - sellingEnabled = productAccess.sellingEnabled, - ) - Spacer(Modifier.height(Space.x16)) - - BenefitRow(stringResource(Res.string.paywall_benefit_course)) - BenefitRow(stringResource(Res.string.paywall_benefit_offline)) - BenefitRow(stringResource(Res.string.paywall_benefit_practice)) - - Spacer(Modifier.height(Space.x20)) - StatusCard( - status = status, - plan = plan, - sellingEnabled = productAccess.sellingEnabled, - productAccess = productAccess, - ) - - error?.let { - Spacer(Modifier.height(Space.x12)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - Spacer(Modifier.height(Space.x20)) - if (!entitled && productAccess.sellingEnabled) { - PrimaryButton( - text = subscribeLabel(plan), - onClick = { - Analytics.track("pro_cta_tapped", mapOf("source" to source)) - activity?.let { vm.subscribe(it) } - }, - enabled = plan != null && status != SubscriptionStatus.PENDING, - ) - } else if (!entitled) { - // The server has not put a plan on sale: show no price and no - // purchase, and say why instead of inviting a buy that cannot - // happen. Never invents a number. - Text( - text = stringResource(Res.string.paywall_not_for_sale), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(Modifier.height(Space.x8)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - QuietTextButton(stringResource(Res.string.paywall_restore), onClick = { vm.refresh() }) - } - - if (status != SubscriptionStatus.NONE) { - Spacer(Modifier.height(Space.x4)) - Text( - text = stringResource(Res.string.paywall_manage), - style = MaterialTheme.typography.labelLarge, - color = GlossoColors.accentText, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = Sizes.touch) - .wrapContentHeight(Alignment.CenterVertically) - .clickable { actions.openSubscriptionManagement(productId(plan, config)) } - .padding(vertical = Space.x8), - textAlign = TextAlign.Center, - ) - } - - Spacer(Modifier.height(Space.x12)) - Hairline() - Spacer(Modifier.height(Space.x12)) - Text( - text = legalNote(plan), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun subscribeLabel(plan: SubscriptionPlan?): String { - if (plan == null) return stringResource(Res.string.paywall_cta_default) - plan.trialLabel()?.let { return stringResource(Res.string.paywall_cta_trial, it) } - if (plan.priceFormatted.isNotBlank()) { - return stringResource(Res.string.paywall_cta_price, plan.priceFormatted) - } - return stringResource(Res.string.paywall_cta_default) -} - -/** - * The offer block, and the only place a price can be drawn. It is shown only when - * the server has put a plan on sale: with a null price there must be no figure on - * screen, not even one Play already returned, so the paywall keeps the honest - * "not on sale" line and nothing that reads like a price next to it. - */ -@Composable -internal fun PaywallOffer( - plan: SubscriptionPlan?, - loading: Boolean, - sellingEnabled: Boolean, -) { - if (!sellingEnabled) return - Spacer(Modifier.height(Space.x20)) - OfferCard(plan = plan, loading = loading) -} - -@Composable -private fun OfferCard(plan: SubscriptionPlan?, loading: Boolean) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(Radius.l), - border = cardBorder(), - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(Space.x16)) { - Text( - text = plan?.title?.takeIf { it.isNotBlank() } - ?: stringResource(Res.string.paywall_default_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(Space.x8)) - val line = plan?.offerLine()?.takeIf { it.isNotBlank() } - Text( - text = when { - line != null -> line - loading -> stringResource(Res.string.paywall_price_loading) - else -> stringResource(Res.string.paywall_price_unavailable) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - plan?.description?.takeIf { it.isNotBlank() }?.let { - Spacer(Modifier.height(Space.x4)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun BenefitRow(text: String) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = Space.x8), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(Space.x12)) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} - -@Composable -internal fun StatusCard( - status: SubscriptionStatus, - plan: SubscriptionPlan?, - sellingEnabled: Boolean, - productAccess: ProductAccess = ProductAccess.Unknown, -) { - val message: String = when { - productAccess.lifetimeGrant != null -> { - if (productAccess.betaFounder) { - stringResource(Res.string.entitlement_lifetime_founder) - } else { - stringResource(Res.string.entitlement_lifetime_pro) - } - } - status == SubscriptionStatus.NONE -> return - status == SubscriptionStatus.TRIAL -> { - // The price is a figure, so it is painted only when the server has - // actually put a plan on sale. With selling off, even a price Play - // returned would read like an offer next to the "not on sale" line, - // so the trial status is stated without any number. - val price = plan?.priceFormatted?.takeIf { it.isNotBlank() } - if (sellingEnabled && price != null) { - stringResource(Res.string.paywall_status_trial, price) - } else { - stringResource(Res.string.paywall_status_trial_no_price) - } - } - - status == SubscriptionStatus.ACTIVE -> stringResource(Res.string.paywall_status_active) - status == SubscriptionStatus.GRACE -> stringResource(Res.string.paywall_status_grace) - status == SubscriptionStatus.CANCELED -> stringResource(Res.string.paywall_status_canceled) - status == SubscriptionStatus.PENDING -> stringResource(Res.string.paywall_status_pending) - else -> return - } - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(Radius.m), - border = BorderStroke(1.dp, GlossoColors.success.copy(alpha = 0.5f)), - modifier = Modifier.fillMaxWidth(), - ) { - Row(Modifier.padding(Space.x16), verticalAlignment = Alignment.CenterVertically) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@Composable -private fun legalNote(plan: SubscriptionPlan?): String { - val frequency = plan?.let { periodFrequency(it.recurringPeriodIso) } - val base = if (frequency != null) { - stringResource(Res.string.paywall_legal, frequency) - } else { - stringResource(Res.string.paywall_legal_no_frequency) - } - val trial = plan?.trialLabel() - val trialWarning = trial?.let { stringResource(Res.string.paywall_legal_trial, it) } - // The sentences carry no leading or trailing whitespace and are joined with - // a plain space, so the note can never read "...canceles.La prueba...". - return listOfNotNull( - base, - trialWarning?.takeIf { it.isNotBlank() }, - stringResource(Res.string.paywall_legal_manage), - ).joinToString(" ") -} - -private fun productId(plan: SubscriptionPlan?, config: AppConfig): String = - plan?.productId?.takeIf { it.isNotBlank() } ?: config.subscriptionProductId diff --git a/shared/src/commonMain/kotlin/com/glosso/app/PracticeViewModel.kt b/shared/src/commonMain/kotlin/com/glosso/app/PracticeViewModel.kt index 52b9a5a..4f17b45 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/PracticeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/PracticeViewModel.kt @@ -5,10 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.glosso.app.api.DrillPlanRequest import com.glosso.app.api.GlossoApi -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus import com.glosso.engine.drills.DrillPlanner import com.glosso.engine.model.DrillModel import com.glosso.engine.model.PhonemeErrorModel @@ -114,29 +110,6 @@ class PracticeViewModel(private val services: AppServices) : ViewModel() { private val _state = MutableStateFlow(PracticeState()) val state: StateFlow = _state.asStateFlow() - /** - * The subscription the gate reads. Free learners only ever get the general A1 - * bank (the "try anything" hook); Pro learners get the plan built from their - * profile. Set from the screen so the same [AccessController] decides, not a - * boolean re-derived in the UI. - */ - private var status: SubscriptionStatus = SubscriptionStatus.NONE - - /** The server's product access (open beta, plan); set alongside [status]. */ - private var productAccess: ProductAccess = ProductAccess.Unknown - - /** - * Called when the practice screen is (re)entered with the current status. It - * always re-resolves the plan, so a profile recorded since the last visit - * turns the free bank into the personalised one (and a lapsed subscription - * turns it back, still A1-only). - */ - fun setAccess(status: SubscriptionStatus, productAccess: ProductAccess = ProductAccess.Unknown) { - this.status = status - this.productAccess = productAccess - load() - } - init { engine.onScored = { feedback -> val previous = AttemptHistory.recordTake(_state.value.drill?.id, feedback.score) @@ -162,25 +135,20 @@ class PracticeViewModel(private val services: AppServices) : ViewModel() { viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null) profiles.await() - // Personalisation is Pro: a free learner gets the general bank, and - // whatever the bank returns is still filtered item by item, so no A2+ - // drill can slip into a free session. - val access = AccessController(status, productAccess = productAccess) - val profile = if (access.entitled) profiles.current?.takeIf { it.stats.isNotEmpty() } else null - val bankLevel = if (access.entitled) null else AccessPolicy.FREE_LEVEL + val profile = profiles.current?.takeIf { it.stats.isNotEmpty() } runCatching { planPractice( profile = profile, plan = { active -> api.drillPlan(DrillPlanRequest(profile = active, limit = PRACTICE_SIZE)).drills }, - fallback = { api.drills(limit = PRACTICE_SIZE, level = bankLevel) }, + fallback = { api.drills(limit = PRACTICE_SIZE) }, ) } .onSuccess { (drills, source) -> _state.value = _state.value.copy( loading = false, - drills = drills.filterNot { access.isLocked(it) }, + drills = drills, index = 0, source = source, focus = null, @@ -234,24 +202,12 @@ class PracticeViewModel(private val services: AppServices) : ViewModel() { fun practiceError(error: PhonemeErrorModel) { viewModelScope.launch { val currentId = _state.value.drill?.id - val access = AccessController(status, productAccess = productAccess) // Prefer a different drill from the reviewed bank, so the learner - // generalises the sound instead of replaying the same sentence. The - // bank is filtered through the gate too, so a free learner is never - // handed an A2+ drill by the "practise this sound" shortcut. - val bank = runCatching { - api.drills( - limit = DRILL_BANK, - level = if (access.entitled) null else AccessPolicy.FREE_LEVEL, - ) - }.getOrDefault(emptyList()) - val pool = (bank + _state.value.drills) - .filter { it.id != currentId && !access.isLocked(it) } + // generalises the sound instead of replaying the same sentence. + val bank = runCatching { api.drills(limit = DRILL_BANK) }.getOrDefault(emptyList()) + val pool = (bank + _state.value.drills).filter { it.id != currentId } val drill = DrillPlanner.drillForError(error, pool) - ?: DrillPlanner.drillForError( - error, - _state.value.drills.filterNot { access.isLocked(it) }, - ) + ?: DrillPlanner.drillForError(error, _state.value.drills) ?: run { // No drill trains this sound right now (offline bank or a sound // the bank does not cover). Say so instead of a dead tap. diff --git a/shared/src/commonMain/kotlin/com/glosso/app/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/SettingsScreen.kt index 1502f36..109454a 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/SettingsScreen.kt @@ -97,7 +97,7 @@ fun SettingsScreen(onBack: () -> Unit, vm: ScoringViewModel = scoringViewModel() if (showRecovery) { TrackScreen("recovery") PlatformBackHandler { showRecovery = false } - FounderRecoveryScreen(onBack = { showRecovery = false }) + ChangePhoneScreen(onBack = { showRecovery = false }) return } diff --git a/shared/src/commonMain/kotlin/com/glosso/app/StoriesScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/StoriesScreen.kt index f049193..9515ec2 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/StoriesScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/StoriesScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoStories -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,9 +36,6 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.glosso.app.audio.PieceState -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus import com.glosso.app.designsystem.Space import com.glosso.app.resources.* import com.glosso.engine.model.ExerciseModel @@ -50,15 +46,9 @@ import org.jetbrains.compose.resources.stringResource @Composable fun StoriesScreen( vm: StoriesViewModel = storiesViewModel(), - subscription: SubscriptionStatus = SubscriptionStatus.NONE, - productAccess: ProductAccess = ProductAccess.Unknown, - onShowPaywall: (String?) -> Unit = {}, onBack: () -> Unit, ) { val state by vm.state.collectAsState() - // One gate for the whole list: each story carries its own CEFR level and the - // controller answers per item, so no row re-derives the frontier. - val access = remember(subscription, productAccess) { AccessController(subscription, productAccess = productAccess) } val story = state.reading if (story != null) { @@ -99,13 +89,12 @@ fun StoriesScreen( contentPadding = PaddingValues(start = Space.gutter, end = Space.gutter, bottom = listBottomPadding()), ) { itemsIndexed(state.stories, key = { _, it -> it.id }) { index, item -> - val locked = access.isLocked(item) - val open = { if (locked) onShowPaywall(item.level) else vm.openStory(item) } + val open = { vm.openStory(item) } StaggeredAppear(index = index) { if (index == 0) { - StoryRow(story = item, labels = state.themeLabels, locked = locked, onClick = open) + StoryRow(story = item, labels = state.themeLabels, onClick = open) } else { - StoryCompactRow(story = item, labels = state.themeLabels, locked = locked, onClick = open) + StoryCompactRow(story = item, labels = state.themeLabels, onClick = open) } } Hairline() @@ -119,7 +108,6 @@ fun StoriesScreen( private fun StoryRow( story: StoryModel, labels: Map, - locked: Boolean, onClick: () -> Unit, ) { Column( @@ -136,15 +124,6 @@ private fun StoryRow( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (locked) { - Spacer(Modifier.width(Space.x8)) - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = stringResource(Res.string.lessons_unlock_with_pro), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - } } Spacer(Modifier.height(Space.x8)) Text( @@ -170,7 +149,6 @@ private fun StoryRow( private fun StoryCompactRow( story: StoryModel, labels: Map, - locked: Boolean, onClick: () -> Unit, ) { val interaction = remember { MutableInteractionSource() } @@ -196,15 +174,6 @@ private fun StoryCompactRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (locked) { - Spacer(Modifier.width(Space.x8)) - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = stringResource(Res.string.lessons_unlock_with_pro), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - } } Spacer(Modifier.height(Space.x4)) Text( diff --git a/shared/src/commonMain/kotlin/com/glosso/app/SubscriptionViewModel.kt b/shared/src/commonMain/kotlin/com/glosso/app/SubscriptionViewModel.kt deleted file mode 100644 index 6173966..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/SubscriptionViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.glosso.app - -import androidx.lifecycle.ViewModel -import com.glosso.app.billing.SubscriptionPlan -import com.glosso.app.billing.SubscriptionRepository -import com.glosso.app.billing.SubscriptionStatus -import com.glosso.app.platform.PlatformActivity -import kotlinx.coroutines.flow.StateFlow - -/** - * The UI's view of the subscription. It only forwards to the repository, so the - * screens depend on state, not on Google Play. - */ -class SubscriptionViewModel(private val repository: SubscriptionRepository) : ViewModel() { - - val status: StateFlow = repository.status - val plan: StateFlow = repository.plan - val loading: StateFlow = repository.loading - val error: StateFlow = repository.error - - init { - // Connect, load the offer and restore on first use. Idempotent. - repository.start() - } - - fun refresh() = repository.refresh(restore = true) - - fun subscribe(activity: PlatformActivity) = repository.subscribe(activity) - - fun clearError() = repository.clearError() -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/Support.kt b/shared/src/commonMain/kotlin/com/glosso/app/Support.kt new file mode 100644 index 0000000..a4a4036 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/glosso/app/Support.kt @@ -0,0 +1,54 @@ +package com.glosso.app + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.LocalCafe +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import com.glosso.app.designsystem.Space +import com.glosso.app.resources.* +import org.jetbrains.compose.resources.stringResource + +/** The Ko-fi page for voluntary donations; blank hides every "Support Glosso" entry. */ +const val KOFI_URL = "" + +/** + * The "Support Glosso" row: Glosso is free and open source, and a coffee helps + * keep the servers running. It opens [url] in the browser and renders nothing + * while [url] is blank. + */ +@Composable +internal fun SupportGlossoRow(modifier: Modifier = Modifier, url: String = KOFI_URL) { + if (url.isBlank()) return + val uriHandler = LocalUriHandler.current + HubRow( + icon = Icons.Outlined.LocalCafe, + title = stringResource(Res.string.support_title), + subtitle = stringResource(Res.string.support_subtitle), + onClick = { runCatching { uriHandler.openUri(url) } }, + modifier = modifier, + ) +} + +/** About's "Support Glosso" section, in the screen's own label, line and link form; hidden while [url] is blank. */ +@Composable +internal fun SupportGlossoSection(onOpen: (String) -> Unit, url: String = KOFI_URL) { + if (url.isBlank()) return + Column { + Spacer(Modifier.height(Space.x32)) + SectionLabel(stringResource(Res.string.support_title)) + Spacer(Modifier.height(Space.x8)) + Text( + text = stringResource(Res.string.support_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Space.x12)) + AboutLink(stringResource(Res.string.support_link)) { onOpen(url) } + } +} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/TodayScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/TodayScreen.kt index 46bcd3c..d984685 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/TodayScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/TodayScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -37,7 +36,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.dp -import com.glosso.app.billing.TodayPlan import com.glosso.app.designsystem.Radius import com.glosso.app.designsystem.Sizes import com.glosso.app.designsystem.Space @@ -67,8 +65,6 @@ fun TodayScreen( onBrowseLessons: () -> Unit, onReviewSounds: () -> Unit, onSettings: () -> Unit, - soundsLocked: Boolean = false, - onUnlockSounds: () -> Unit = {}, offline: Boolean = false, error: String? = null, onRetry: () -> Unit = {}, @@ -116,7 +112,6 @@ fun TodayScreen( loading = loading, canResume = plan.resumable, step = step, - locked = plan.locked, hasLessons = hasLessons, onStart = onStartLesson, onBrowse = onBrowseLessons, @@ -132,9 +127,7 @@ fun TodayScreen( SoundsSection( weak = weak, hasProfile = hasProfile, - locked = soundsLocked, onReview = onReviewSounds, - onUnlock = onUnlockSounds, ) } } @@ -173,7 +166,6 @@ private fun LessonCard( loading: Boolean, canResume: Boolean, step: LessonStep?, - locked: Boolean, hasLessons: Boolean, onStart: () -> Unit, onBrowse: () -> Unit, @@ -181,7 +173,7 @@ private fun LessonCard( LessonHeroSurface( kicker = stringResource(Res.string.today_lesson), tags = if (lesson != null) { - { GlossoTag(if (locked) "Pro" else baseLabel(lesson)) } + { GlossoTag(baseLabel(lesson)) } } else { null }, @@ -219,13 +211,7 @@ private fun LessonCard( } Spacer(Modifier.height(Space.x24)) PrimaryButton( - text = stringResource( - when { - locked -> Res.string.lessons_unlock_with_pro - canResume -> Res.string.today_continue - else -> Res.string.today_start - }, - ), + text = stringResource(if (canResume) Res.string.today_continue else Res.string.today_start), onClick = onStart, ) if (hasLessons) { @@ -260,33 +246,10 @@ private fun LessonCard( private fun SoundsSection( weak: List, hasProfile: Boolean, - locked: Boolean, onReview: () -> Unit, - onUnlock: () -> Unit, ) { val showPhonetics by PhoneticsPreferences.visible.collectAsState() - // The weak sounds are the phonetic profile, which is Pro. A free learner sees - // the same wall the profile shows, kept quiet so it does not compete with the - // lesson action. when { - locked -> Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(Sizes.icon), - ) - Spacer(Modifier.width(Space.x12)) - Text( - text = stringResource(Res.string.today_sounds_locked), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(Space.x8)) - QuietTextButton(stringResource(Res.string.today_sounds_unlock), onClick = onUnlock) - } - !hasProfile -> Text( text = stringResource(Res.string.today_sounds_no_profile), style = MaterialTheme.typography.bodyMedium, diff --git a/shared/src/commonMain/kotlin/com/glosso/app/UpdateNotice.kt b/shared/src/commonMain/kotlin/com/glosso/app/UpdateNotice.kt new file mode 100644 index 0000000..c77c296 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/glosso/app/UpdateNotice.kt @@ -0,0 +1,59 @@ +package com.glosso.app + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.SystemUpdate +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.glosso.app.designsystem.Space +import com.glosso.app.product.VersionNotice +import com.glosso.app.resources.* +import org.jetbrains.compose.resources.stringResource + +/** + * The soft "a newer version is out" strip. It is a notice, not a gate: nothing + * blocks on a version, so it is dismissible and the app keeps working exactly + * as before. + */ +@Composable +internal fun UpdateNotice( + notice: VersionNotice, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!notice.visible) return + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.gutter, vertical = Space.x8), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.SystemUpdate, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(Space.x12)) + Text( + text = stringResource(Res.string.update_notice), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + QuietTextButton(stringResource(Res.string.update_notice_dismiss), onClick = onDismiss) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/ViewModels.kt b/shared/src/commonMain/kotlin/com/glosso/app/ViewModels.kt index 03ff8ff..b36fd8f 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/ViewModels.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/ViewModels.kt @@ -51,9 +51,3 @@ fun profileViewModel(): ProfileViewModel { val services = LocalAppServices.current return viewModel { ProfileViewModel(services.context) } } - -@Composable -fun subscriptionViewModel(): SubscriptionViewModel { - val services = LocalAppServices.current - return viewModel { SubscriptionViewModel(services.subscriptions()) } -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/YouScreen.kt b/shared/src/commonMain/kotlin/com/glosso/app/YouScreen.kt index 862ac41..32a8c73 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/YouScreen.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/YouScreen.kt @@ -41,15 +41,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.glosso.app.billing.AccessController -import com.glosso.app.billing.ProFeature -import com.glosso.app.billing.ProductAccess -import com.glosso.app.billing.SubscriptionStatus import com.glosso.app.designsystem.GlossoColors import com.glosso.app.designsystem.GlossoType import com.glosso.app.designsystem.Radius import com.glosso.app.designsystem.Space -import com.glosso.app.product.ProductConfigRepository import com.glosso.app.resources.* import com.glosso.engine.model.PhonemeStatModel import com.glosso.engine.phonetics.ErrorRules @@ -85,40 +80,13 @@ import org.jetbrains.compose.resources.stringResource @Composable fun YouScreen( vm: ProfileViewModel = profileViewModel(), - subscription: SubscriptionStatus = SubscriptionStatus.NONE, - productAccess: ProductAccess = ProductAccess.Unknown, - onShowPaywall: (String?) -> Unit = {}, onPractice: () -> Unit = {}, ) { - // The phonetic profile is Pro. A free learner gets the same locked card as - // every other wall; the data itself is never rendered. - val access = remember(subscription, productAccess) { AccessController(subscription, productAccess = productAccess) } - if (access.isLocked(ProFeature.PHONETIC_PROFILE)) { - Column(Modifier.fillMaxSize().statusBarsPadding()) { - TopHeader( - title = stringResource(Res.string.you_title), - ) - Box( - modifier = Modifier.fillMaxSize().padding(horizontal = Space.gutter), - contentAlignment = Alignment.Center, - ) { - ProLockedCard( - title = stringResource(Res.string.pro_locked_title), - message = stringResource(Res.string.pro_locked_message), - onUnlock = { onShowPaywall(null) }, - ) - } - } - return - } - val profile by vm.profile.collectAsState() val history by vm.history.collectAsState() val progress by ProgressStore.state.collectAsState() val baselines by PhonemeHistory.baselines.collectAsState() val showPhonetics by PhoneticsPreferences.visible.collectAsState() - val betaConfig by ProductConfigRepository.config.collectAsState() - val betaAccount by ProductConfigRepository.account.collectAsState() val baseline = baselines[LanguagePreferences.current.code] val today = todayEpochDay() val stats = profile?.stats.orEmpty().sortedWith(compareBy({ it.accuracy }, { it.phoneme })) @@ -247,13 +215,6 @@ fun YouScreen( Spacer(Modifier.height(Space.x24)) } - if (betaConfig.isBeta || betaConfig.beta.active) { - item { - BetaBanner(config = betaConfig, account = betaAccount) - Spacer(Modifier.height(Space.x24)) - } - } - if (skills.isNotEmpty()) { item { SectionLabel(stringResource(Res.string.you_competences)) @@ -276,6 +237,13 @@ fun YouScreen( Spacer(Modifier.height(Space.x12)) AchievementsGrid(progress, signals) } + + if (KOFI_URL.isNotBlank()) { + item { + Spacer(Modifier.height(Space.x32)) + SupportGlossoRow() + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/glosso/app/api/GlossoApi.kt b/shared/src/commonMain/kotlin/com/glosso/app/api/GlossoApi.kt index 55e4c8b..ea1d207 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/api/GlossoApi.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/api/GlossoApi.kt @@ -78,28 +78,6 @@ data class DrillPlanRequest( val language: String = "", ) -/** - * Phase 2 contract for server-side purchase verification. The subscription flow - * does not call this yet: the server endpoint returns 501 until a Google Play - * service account is configured. It exists so the client and server shapes are - * agreed before the server work starts. See docs/BILLING.md. - */ -@Serializable -data class BillingVerifyRequest( - val productId: String, - val purchaseToken: String, - val packageName: String, - val deviceId: String? = null, -) - -@Serializable -data class BillingVerifyResponse( - val entitled: Boolean, - val status: String, - val entitlementToken: String? = null, - val expiresAtMillis: Long? = null, -) - /** A non-2xx answer from an account call; [status] tells the UI what to say. */ class ApiStatusException(val status: Int) : Exception("HTTP $status") @@ -204,7 +182,7 @@ class GlossoApi( } // Content and assets are immutable per version, so GET responses with // cache headers are reused (and survive offline); the plugin never - // touches POST, so score, events and billing stay uncached. + // touches POST, so score and events stay uncached. install(HttpCache) { publicStorage(contentCache ?: CacheStorage.Disabled) } @@ -249,11 +227,9 @@ class GlossoApi( }.successBody() /** - * The effective access of this device, keyed by `X-Device-Id`. The server is - * the authority on the plan and the Beta Founder status; the client only - * caches and displays them. A non-2xx answer (e.g. 409 when the account - * changed meanwhile) throws [ApiStatusException] instead of decoding the - * error body into an all-defaults FREE account. + * This device's account, keyed by `X-Device-Id`. A non-2xx answer (e.g. 409 + * when the account changed meanwhile) throws [ApiStatusException] instead of + * decoding the error body into an all-defaults account. */ suspend fun me(): AccountState = client.get("$baseUrl/v1/me") { @@ -306,18 +282,6 @@ class GlossoApi( setBody(request.copy(language = request.language.ifBlank { language })) }.body().let { plan -> plan.copy(drills = plan.drills.map { it.forAccent(accent, authoredAccent) }) } - /** - * Phase 2 hollow: asks the server to validate a purchase token with the - * Google Play Developer API. Not wired into the subscription flow yet; the - * server answers 501 until a service account is configured. - */ - suspend fun verifyPurchase(request: BillingVerifyRequest): BillingVerifyResponse = - client.post("$baseUrl/v1/billing/verify") { - noCache() - contentType(ContentType.Application.Json) - setBody(request) - }.body() - /** * A short-lived nonce for Sign in with Google, bound to this device. It goes * into the Credential Manager request, so the ID token Google returns can @@ -331,7 +295,7 @@ class GlossoApi( /** * Signs this device in with the Google account the learner picked. The * first time it links the account where it is; on a new phone the account - * (Beta Founder, lifetime PRO) moves here. Answers the `/v1/me` snapshot; + * moves here. Answers the `/v1/me` snapshot; * 401 is a rejected token and 409 a device linked to another Google account. */ suspend fun signInWithGoogle(idToken: String): AccountState = @@ -596,7 +560,7 @@ class GlossoApi( /** * The decoded body of a 2xx answer, or [ApiStatusException]. The account * calls need it: [AccountState] has defaults for every field, so an error - * body like `{"error": ...}` would otherwise decode into a FREE account. + * body like `{"error": ...}` would otherwise decode into an empty account. */ private suspend inline fun io.ktor.client.statement.HttpResponse.successBody(): T { if (!status.isSuccess()) throw ApiStatusException(status.value) diff --git a/shared/src/commonMain/kotlin/com/glosso/app/api/ProductConfig.kt b/shared/src/commonMain/kotlin/com/glosso/app/api/ProductConfig.kt index e258978..52a83ba 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/api/ProductConfig.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/api/ProductConfig.kt @@ -5,137 +5,38 @@ import kotlinx.serialization.Serializable /** * The product configuration the server owns (`GET /v1/config`), frozen in - * docs/PRODUCT-CONFIG-CONTRACT.md. Everything here is read-only for the client: - * the phase, the beta window, the three plans, the limits, the prices, the - * feature flags and the client version policy all come from the server, and the - * app never writes any of it back. + * docs/PRODUCT-CONFIG-CONTRACT.md. The client only reads the feature flags and + * the client version policy, and never writes any of it back. Other keys the + * server still sends for older app versions are ignored. * - * Every field has a conservative default so a server that predates a key, or a - * body missing it, decodes into something safe instead of throwing. + * Every field has a default so a server that predates a key, or a body missing + * it, decodes into something safe instead of throwing. */ @Serializable data class ProductConfig( val schema: Int = 1, - val mode: String = MODE_BETA, - val beta: BetaConfig = BetaConfig(), - val plans: Map = emptyMap(), - /** - * Null while the server has not decided any limit (it serves `"limits": null`). - * A null here is not "no limits declared": the whole body is kept, and the - * absent map behaves exactly like an empty one when read. - */ - val limits: Map? = null, - /** Null while no price is decided (`"pricing": null`); see [limits]. */ - val pricing: Map? = null, val flags: Map = emptyMap(), val client: ClientConfig = ClientConfig(), ) { - companion object { - const val MODE_BETA = "BETA" - const val MODE_FREE = "FREE" - - /** - * What the app assumes when neither the server nor the cache has ever - * answered. During the beta this is the honest state: the beta is open, - * so nothing is locked and nothing is sold, and the absence of an end - * date just means the UI says "during the beta" instead of a date. It is - * fail-open, never a wall invented by the client. - */ - val BetaFallback = ProductConfig( - mode = MODE_BETA, - beta = BetaConfig(active = true), - ) - } - - /** True while the product is in its open beta phase. */ - val isBeta: Boolean get() = mode.equals(MODE_BETA, ignoreCase = true) - /** A feature flag is on only when the server says so; a missing key is false. */ fun flag(name: String): Boolean = flags[name] == true - - /** The declared entitlements of a plan, or null when the plan is not in the config. */ - fun featuresOf(plan: String): PlanFeatures? = plans[plan] - - /** The published price of a plan; null (or nulls inside) means "not for sale yet". */ - fun pricingOf(plan: String): PlanPricing? = pricing?.get(plan) } -@Serializable -data class BetaConfig( - val active: Boolean = false, - /** ISO-8601 UTC; the only source of the beta end date in the whole app. */ - val endDate: String? = null, - val founderEnabled: Boolean = false, - val founderBenefit: FounderBenefit? = null, -) - -@Serializable -data class FounderBenefit( - val kind: String? = null, - val value: Double? = null, - val until: String? = null, - val label: String? = null, -) - -/** What a plan includes. The strings are the server's vocabulary, not the app's. */ -@Serializable -data class PlanFeatures( - @SerialName("pronunciation_analysis") val pronunciationAnalysis: String = "", - val exercises: String = "", - val analytics: String = "", - val personalization: String = "", -) - -/** Optional per-plan limits; a limit the server does not declare does not apply. */ -@Serializable -data class PlanLimits( - @SerialName("pronunciation_analysis_per_day") val pronunciationAnalysisPerDay: Int? = null, - @SerialName("history_days") val historyDays: Int? = null, -) - -/** - * A plan's price. Any field may be null: the contract says null means the price - * is not decided, and the UI then shows no price and sells nothing. - */ -@Serializable -data class PlanPricing( - val monthly: Double? = null, - val yearly: Double? = null, - val currency: String? = null, -) { - /** True only when at least one real amount is published. */ - val published: Boolean get() = (monthly != null && monthly > 0) || (yearly != null && yearly > 0) -} - -/** The version policy. The client only ever warns; it never blocks during the beta. */ +/** The version policy. The client only ever warns; it never blocks. */ @Serializable data class ClientConfig( @SerialName("minimumSupportedVersion") val minimumSupportedVersion: Int = 0, @SerialName("recommendedVersion") val recommendedVersion: Int = 0, /** - * Decoded but intentionally unused: during the beta the version policy is - * only ever a soft, dismissible notice (`AccessPolicy.versionNotice`), never - * a hard gate. It is kept here so the day the product leaves beta the server - * flag is already part of the model. + * Decoded but intentionally unused: the version policy is only ever a soft, + * dismissible notice ([com.glosso.app.product.VersionNotice]), never a hard gate. */ val forceUpdate: Boolean = false, ) -/** - * The effective access of one device (`GET /v1/me`), authenticated by - * `X-Device-Id`. [plan] is the server's decision; [betaFounder] is an orthogonal - * status, never nested into the plan. [betaFounderBenefit] is the benefit label - * the server derives from `beta.founderBenefit.label`, sent as plain text (not - * the config object). - */ +/** This device's account (`GET /v1/me`), authenticated by `X-Device-Id`. */ @Serializable data class AccountState( - val plan: String = "FREE", - val status: String = "NONE", - val betaFounder: Boolean = false, - val betaFounderSince: String? = null, - val betaFounderBenefit: String? = null, - val expiresAt: String? = null, /** * The external accounts this account is linked to (e.g. `google`). Null * when the server does not report it, which says nothing either way. diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessController.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessController.kt deleted file mode 100644 index 1200426..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.engine.model.ContentMeta -import com.glosso.engine.model.LessonModel - -/** - * The one object the UI asks about access. It binds the current entitlement and - * the in-progress lesson into per-item answers, so a screen never re-derives the - * free frontier or threads the raw subscription status through every check. - * - * It answers for every content kind (lessons, stories, exercises, drills) through - * [ContentMeta], because they all carry the CEFR level the frontier reads, and for - * the feature surfaces that have no level ([ProFeature]). A screen that opens - * content asks this object; nothing else decides. - * - * It is a pure value of the current state: rebuild it when the entitlement or the - * in-progress lesson changes. [AccessPolicy] stays the only place the rules live; - * this only holds the state the rules read. - */ -class AccessController( - private val status: SubscriptionStatus, - private val inProgressLessonId: String? = null, - private val productAccess: ProductAccess = ProductAccess.Unknown, -) { - - /** True while the learner is treated as Pro. */ - val entitled: Boolean = AccessPolicy.isEntitled(status, productAccess) - - /** The access side of any content item, independent of the subscription. */ - fun accessOf(item: ContentMeta): LessonAccess = AccessPolicy.accessOf(item.level) - - /** The access side of [lesson], independent of the subscription. */ - fun accessOf(lesson: LessonModel): LessonAccess = AccessPolicy.accessOf(lesson.level) - - /** - * True when opening [item] must show the paywall instead of the content. A - * free learner only reaches A1; an entitled learner reaches everything. - */ - fun isLocked(item: ContentMeta): Boolean = - !entitled && accessOf(item) == LessonAccess.PRO - - /** True when [lesson] must open the paywall instead of the lesson. */ - fun isLocked(lesson: LessonModel): Boolean = - !AccessPolicy.isLessonUnlocked(lesson, status, inProgressLessonId, productAccess) - - /** True when the Pro [feature] must open the paywall instead of the surface. */ - fun isLocked(feature: ProFeature): Boolean = - !AccessPolicy.isFeatureUnlocked(status, feature, productAccess) - - /** True when [lesson] may be downloaded for offline use by this learner. */ - fun canDownload(lesson: LessonModel): Boolean = - AccessPolicy.canDownloadOffline(lesson, status, productAccess) -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessPolicy.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessPolicy.kt deleted file mode 100644 index c497e7f..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/AccessPolicy.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.api.AccountState -import com.glosso.app.api.ProductConfig -import com.glosso.engine.model.LessonModel - -/** - * The access side of one content item. It is a property of the item, not of the - * screen that shows it: [FREE] is in the always-free level, [PRO] is behind the - * subscription. The UI reads this and decides with the current entitlement; it - * never re-derives the frontier itself. - */ -enum class LessonAccess { FREE, PRO } - -/** - * A Pro surface that is not itself a content item and so has no CEFR level to - * read. It follows the entitlement directly: either the learner is Pro or the - * surface asks for the paywall. Named here so a screen never invents its own - * "is this Pro?" check. - */ -enum class ProFeature { - /** Practice planned from the learner's phonetic profile. */ - PERSONALIZED_PRACTICE, - - /** The phonetic profile itself. */ - PHONETIC_PROFILE, -} - -/** - * The single place where "what is free and what is paid" is defined. - * - * The frontier is the CEFR level, not an arbitrary block inside one: the whole of - * [FREE_LEVEL] (A1) is free, and from the next level on everything is Pro. That - * makes the rule easy to state in one sentence - "finish A1 for free, continue - * with A2 and beyond on Pro" - and impossible to hit mid-level: no learner sees - * the wall between two lessons of the same level. A lesson that is already in - * progress is always continuable, even at a paid level, so the access decision - * happens once, before starting, and never mid-lesson. - * - * Offline downloads mirror the same frontier: the free level is downloadable by - * anyone, the paid levels only by an entitled learner. - * - * This is a UX gate, not a security boundary: entitlement is enforced for real by - * server-side verification of the purchase token (phase 2, see docs/BILLING.md), - * because a client can always be tampered with. Change the product decision here - * and nowhere else. - */ -object AccessPolicy { - - /** The only level that is free: the whole of A1 is on us. */ - const val FREE_LEVEL = "A1" - - /** True when [status] should unlock every Pro surface, ignoring the product phase. */ - fun isEntitled(status: SubscriptionStatus): Boolean = status.isEntitled - - /** - * The access the server grants this device, from `GET /v1/config` and - * `GET /v1/me`. The client decides nothing here: it reads the phase and the - * plan the server publishes. - * - * While the beta is active the whole course is [ProductAccess.betaFree] and - * nothing is sold. Selling only turns on once the product has left the beta, - * the PRO flag is on and a real price has been published; a null price keeps - * it off, so the app never invents a number. - */ - fun productAccess(config: ProductConfig, account: AccountState?): ProductAccess { - val betaFree = config.isBeta || config.beta.active - val selling = !betaFree && - config.flag("pro") && - config.pricingOf(ProductPlan.PRO.name)?.published == true - val betaFounder = account?.betaFounder == true - val grant = when { - betaFounder -> EntitlementGrant( - plan = ProductPlan.PRO, - kind = EntitlementKind.LIFETIME, - source = "beta_founder", - label = account.betaFounderBenefit ?: config.beta.founderBenefit?.label ?: "Beta Founder", - ) - else -> null - } - return ProductAccess( - betaFree = betaFree, - plan = ProductPlan.fromWire(account?.plan), - sellingEnabled = selling, - betaFounder = betaFounder, - lifetimeGrant = grant, - ) - } - - /** The version policy from the config, for a soft, non-blocking notice. */ - fun versionNotice(config: ProductConfig, currentVersion: Int): VersionNotice = - VersionNotice( - currentVersion = currentVersion, - recommendedVersion = config.client.recommendedVersion, - minimumSupportedVersion = config.client.minimumSupportedVersion, - ) - - /** - * True when [status] unlocks every Pro content surface, including the open - * beta and a Beta Founder: both reach the PRO content tier whatever the - * local Play state says. Personalization is separate, see [isPersonalized]. - */ - fun isEntitled(status: SubscriptionStatus, access: ProductAccess): Boolean = - access.entitled || status.isEntitled - - /** - * True when [status] unlocks the personalization tier. This is the top plan, - * not the PRO content tier: the open beta opens it, a local Play subscription - * opens it, and a server-granted PERSONALIZED plan opens it. A Beta Founder - * is granted PRO, never PERSONALIZED, so it does not open it. - */ - fun isPersonalized(status: SubscriptionStatus, access: ProductAccess): Boolean = - access.personalized || status.isEntitled - - /** - * The theme order the course is taught in: the manifest order first, then any - * theme the manifest did not list, in the order it first appears. Used to sort - * the lesson list, never to decide what is free. - */ - fun blockOrder(lessons: List, themeOrder: List = emptyList()): List { - val present = lessons.asSequence() - .map { it.theme } - .filter { it.isNotBlank() } - .distinct() - .toList() - val known = themeOrder.filter { it in present } - return known + present.filterNot { it in known } - } - - /** - * The order of the lessons inside one block: by level, then by the kind of - * base (a dialogue before a story before an article), then by id. The lesson - * list and the "what is next" decision share it, so the hero can never offer - * a lesson that is not the next row of the list. - */ - val lessonOrder: Comparator = compareBy({ it.level }, { baseRank(it) }, { it.id }) - - private fun baseRank(lesson: LessonModel): Int = when { - lesson.dialogueId != null -> 0 - lesson.storyId != null -> 1 - lesson.articleId != null -> 2 - else -> 3 - } - - /** Every lesson in the order the course is taught: block by block, then [lessonOrder]. */ - fun teachingOrder(lessons: List, themeOrder: List = emptyList()): List { - val byTheme = lessons.groupBy { it.theme } - return blockOrder(lessons, themeOrder).flatMap { byTheme[it].orEmpty().sortedWith(lessonOrder) } - } - - /** - * The first lesson in teaching order: the one a new learner should meet. Null - * only when there is no lesson at all. - */ - fun firstLesson(lessons: List, themeOrder: List = emptyList()): LessonModel? = - teachingOrder(lessons, themeOrder).firstOrNull() - - /** - * The lesson to offer when none is in progress: the first one in teaching - * order the learner has not finished yet. Once every lesson is finished there - * is nothing new to offer, so it falls back to the first one rather than to - * an empty card. - */ - fun nextLesson( - lessons: List, - themeOrder: List = emptyList(), - finishedLessonIds: Collection = emptyList(), - ): LessonModel? { - val ordered = teachingOrder(lessons, themeOrder) - return ordered.firstOrNull { it.id !in finishedLessonIds } ?: ordered.firstOrNull() - } - - /** - * The access side of any content level, independent of any subscription. Every - * content item (lesson, story, exercise, drill) carries its CEFR level, so the - * frontier reads the same for all of them: A1 is free, the rest is Pro. - */ - fun accessOf(level: String): LessonAccess = - if (level == FREE_LEVEL) LessonAccess.FREE else LessonAccess.PRO - - /** True when content at [level] is inside the free level. */ - fun isLevelFree(level: String): Boolean = accessOf(level) == LessonAccess.FREE - - /** The access side of [lesson], independent of any subscription. */ - fun accessOf(lesson: LessonModel): LessonAccess = accessOf(lesson.level) - - /** True when [lesson] is inside the free level. */ - fun isLessonFree(lesson: LessonModel): Boolean = isLevelFree(lesson.level) - - /** - * True when [status] unlocks [feature]. The one place a feature-level (not - * item-level) decision is made, so screens never test the entitlement - * themselves. The PRO content tier and the personalization tier are not the - * same: the phonetic profile is PRO analytics, the personalized practice is - * the top PERSONALIZED plan. - */ - fun isFeatureUnlocked( - status: SubscriptionStatus, - feature: ProFeature, - access: ProductAccess = ProductAccess.Unknown, - ): Boolean = - when (feature) { - ProFeature.PERSONALIZED_PRACTICE -> isPersonalized(status, access) - ProFeature.PHONETIC_PROFILE -> isEntitled(status, access) - } - - /** - * True when [lesson] has been opened and must be finishable regardless of the - * entitlement: a learner who started a paid lesson before the trial lapsed - * keeps the right to finish it. - */ - fun isLessonInProgress(lesson: LessonModel, inProgressLessonId: String?): Boolean = - inProgressLessonId != null && lesson.id == inProgressLessonId - - /** - * True when [lesson] can be opened and finished. An in-progress lesson is - * always continuable, so the access decision happens once, before starting. - */ - fun isLessonUnlocked( - lesson: LessonModel, - status: SubscriptionStatus, - inProgressLessonId: String? = null, - access: ProductAccess = ProductAccess.Unknown, - ): Boolean = - isEntitled(status, access) || - isLessonFree(lesson) || - isLessonInProgress(lesson, inProgressLessonId) - - /** True when opening [lesson] must show the paywall instead of the lesson. */ - fun requiresPaywall( - lesson: LessonModel, - status: SubscriptionStatus, - inProgressLessonId: String? = null, - access: ProductAccess = ProductAccess.Unknown, - ): Boolean = !isLessonUnlocked(lesson, status, inProgressLessonId, access) - - /** - * Offline downloads mirror the content frontier instead of gating the whole - * feature: an entitled learner may download any lesson, a free learner may - * download the lessons in the free level. Downloading never assumes the - * learner is Pro. - */ - fun canDownloadOffline( - lesson: LessonModel, - status: SubscriptionStatus, - access: ProductAccess = ProductAccess.Unknown, - ): Boolean = - isEntitled(status, access) || isLessonFree(lesson) -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingPeriods.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingPeriods.kt deleted file mode 100644 index 8facad1..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingPeriods.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.glosso.app.billing - -/** A parsed billing period reduced to a count and a unit, for localization. */ -data class PeriodParts(val count: Int, val unit: PeriodUnit) - -enum class PeriodUnit { DAY, WEEK, MONTH, YEAR } - -/** - * An ISO-8601 date-based period as `java.time.Period` reads it: years, months - * and days, with weeks folded into days and a leading sign applied to all. - */ -data class BillingPeriod(val years: Int, val months: Int, val days: Int) - -/** - * Parses the ISO-8601 billing periods Play returns (e.g. `P1M`, `P7D`, `P1Y`) - * into a count and a unit. Kept in one place so the paywall never invents a - * duration Play did not return, and the wording is localized at the UI. - */ -object BillingPeriods { - - // The grammar of java.time.Period.parse: an optional sign, then P and at least - // one of the Y, M, W and D parts in that order, each with its own optional sign. - private val PATTERN = Regex( - "([-+]?)P(?:([-+]?[0-9]+)Y)?(?:([-+]?[0-9]+)M)?(?:([-+]?[0-9]+)W)?(?:([-+]?[0-9]+)D)?", - RegexOption.IGNORE_CASE, - ) - - /** A period, or null when [iso] is blank or cannot be parsed. */ - fun parse(iso: String?): BillingPeriod? { - if (iso.isNullOrBlank()) return null - val match = PATTERN.matchEntire(iso) ?: return null - val (sign, y, m, w, d) = match.destructured - if (y.isEmpty() && m.isEmpty() && w.isEmpty() && d.isEmpty()) return null - return runCatching { - val negate = sign == "-" - fun part(text: String): Int { - if (text.isEmpty()) return 0 - val value = text.removePrefix("+").toInt() - return if (negate) exactInt(-value.toLong()) else value - } - val days = exactInt(part(d).toLong() + part(w).toLong() * 7) - BillingPeriod(years = part(y), months = part(m), days = days) - }.getOrNull() - } - - /** The period as a count and a unit, or null when empty/unparseable. */ - fun parts(iso: String?): PeriodParts? { - val period = parse(iso) ?: return null - val years = period.years - val months = period.months - val days = period.days - return when { - years > 0 -> PeriodParts(years, PeriodUnit.YEAR) - months > 0 -> PeriodParts(months, PeriodUnit.MONTH) - days % 7 == 0 && days >= 7 -> PeriodParts(days / 7, PeriodUnit.WEEK) - days > 0 -> PeriodParts(days, PeriodUnit.DAY) - else -> null - } - } - - /** - * The instant a trial starting at [startMillis] ends, or null without a trial. - * Calendar arithmetic in UTC, as `ZonedDateTime.plus(Period)`: months first - * (a day past the end of the month clamps to its last day), then days. - */ - fun endMillis(startMillis: Long, trialPeriodIso: String?): Long? { - val period = parse(trialPeriodIso) ?: return null - return runCatching { - val epochDay = startMillis.floorDiv(MILLIS_PER_DAY) - val timeOfDay = startMillis.mod(MILLIS_PER_DAY) - val date = CivilDate.ofEpochDay(epochDay) - val totalMonths = period.years.toLong() * 12 + period.months - val shifted = if (totalMonths == 0L) date else date.plusMonths(totalMonths) - val end = shifted.toEpochDay() + period.days - require(end in MIN_EPOCH_DAY..MAX_EPOCH_DAY) { "out of range" } - end * MILLIS_PER_DAY + timeOfDay - }.getOrNull() - } - - private const val MILLIS_PER_DAY = 86_400_000L - - // The epoch days whose millis fit in a Long, so the product cannot overflow. - private const val MAX_EPOCH_DAY = Long.MAX_VALUE / MILLIS_PER_DAY - 1 - private const val MIN_EPOCH_DAY = -MAX_EPOCH_DAY - - private fun exactInt(value: Long): Int { - require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "overflow" } - return value.toInt() - } -} - -/** A proleptic Gregorian date, enough for [BillingPeriods.endMillis]. */ -private data class CivilDate(val year: Long, val month: Int, val day: Int) { - - fun plusMonths(months: Long): CivilDate { - val index = year * 12 + (month - 1) + months - val newYear = index.floorDiv(12L) - val newMonth = index.mod(12L).toInt() + 1 - return CivilDate(newYear, newMonth, minOf(day, lengthOf(newYear, newMonth))) - } - - // Howard Hinnant's days_from_civil. - fun toEpochDay(): Long { - val y = if (month <= 2) year - 1 else year - val era = y.floorDiv(400L) - val yoe = y - era * 400 - val mp = (month + 9) % 12 - val doy = (153 * mp + 2) / 5 + day - 1 - val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy - return era * 146_097 + doe - 719_468 - } - - companion object { - // Howard Hinnant's civil_from_days. - fun ofEpochDay(epochDay: Long): CivilDate { - val z = epochDay + 719_468 - val era = z.floorDiv(146_097L) - val doe = z - era * 146_097 - val yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365 - val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) - val mp = (5 * doy + 2) / 153 - val day = (doy - (153 * mp + 2) / 5 + 1).toInt() - val month = if (mp < 10) (mp + 3).toInt() else (mp - 9).toInt() - val year = yoe + era * 400 + if (month <= 2) 1 else 0 - return CivilDate(year, month, day) - } - - private fun lengthOf(year: Long, month: Int): Int = when (month) { - 2 -> if (year % 4 == 0L && (year % 100 != 0L || year % 400 == 0L)) 29 else 28 - 4, 6, 9, 11 -> 30 - else -> 31 - } - } -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingText.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingText.kt deleted file mode 100644 index f8e9c9e..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/BillingText.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.glosso.app.billing - -import androidx.compose.runtime.Composable -import com.glosso.app.resources.* -import org.jetbrains.compose.resources.pluralStringResource -import org.jetbrains.compose.resources.stringResource - -/** - * Localized wording for the billing periods. The ISO values come from Play; only - * the human form lives here, in the interface language. - */ - -/** Human label for an ISO-8601 period, e.g. "1 mes", "3 months". */ -@Composable -fun periodLabel(iso: String?): String? { - val parts = BillingPeriods.parts(iso) ?: return null - val plural = when (parts.unit) { - PeriodUnit.DAY -> Res.plurals.billing_period_days - PeriodUnit.WEEK -> Res.plurals.billing_period_weeks - PeriodUnit.MONTH -> Res.plurals.billing_period_months - PeriodUnit.YEAR -> Res.plurals.billing_period_years - } - return pluralStringResource(plural, parts.count, parts.count) -} - -/** Frequency phrase for a recurring charge, e.g. "al mes", "every 3 months". */ -@Composable -fun periodFrequency(iso: String?): String? { - val parts = BillingPeriods.parts(iso) ?: return null - val plural = when (parts.unit) { - PeriodUnit.DAY -> Res.plurals.billing_freq_days - PeriodUnit.WEEK -> Res.plurals.billing_freq_weeks - PeriodUnit.MONTH -> Res.plurals.billing_freq_months - PeriodUnit.YEAR -> Res.plurals.billing_freq_years - } - return pluralStringResource(plural, parts.count, parts.count) -} - -/** The free-trial length in words, or null when the offer has no trial. */ -@Composable -fun SubscriptionPlan.trialLabel(): String? = periodLabel(freeTrialPeriod) - -/** - * The honest one-liner the paywall shows, built only from Play's own price and - * period: "1 mes gratis, luego 6,99 € al mes". - */ -@Composable -fun SubscriptionPlan.offerLine(): String { - val trial = periodLabel(freeTrialPeriod) - val frequency = periodFrequency(recurringPeriodIso) - val price = priceFormatted.ifBlank { null } - val paid = price?.let { - if (frequency != null) { - stringResource(Res.string.billing_price_with_frequency, it, frequency) - } else { - it - } - } - return when { - hasFreeTrial && paid != null -> - stringResource(Res.string.billing_offer_with_trial, trial.orEmpty(), paid) - - hasFreeTrial -> stringResource(Res.string.billing_offer_trial_only, trial.orEmpty()) - paid != null -> paid - else -> "" - } -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/ProductAccess.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/ProductAccess.kt deleted file mode 100644 index 2100fa6..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/ProductAccess.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.glosso.app.billing - -/** - * The three plans the product declares. There is no fourth: the top tier is - * PERSONALIZED. The server names the plan in `GET /v1/me`; the config only - * describes what each plan includes. - */ -enum class ProductPlan { - FREE, - PRO, - PERSONALIZED, - ; - - companion object { - /** Maps the wire code to a plan. Anything unknown is FREE, never paid. */ - fun fromWire(code: String?): ProductPlan = - entries.firstOrNull { it.name.equals(code?.trim(), ignoreCase = true) } ?: FREE - } -} - -/** - * Kind of entitlement grant: recurring subscriptions vs permanent lifetime grants. - */ -enum class EntitlementKind { - /** A recurring subscription (Play Billing, monthly/yearly with auto-renewal). */ - SUBSCRIPTION, - - /** A permanent lifetime grant (one-time purchase, Beta Founder, manual grant). Never expires, no renewal. */ - LIFETIME, -} - -/** - * A concrete entitlement held by this device. - */ -data class EntitlementGrant( - val plan: ProductPlan, - val kind: EntitlementKind = EntitlementKind.LIFETIME, - val source: String = "beta_founder", - val label: String? = null, -) - -/** - * The access the server grants this device, computed once from the product - * config and the `/v1/me` answer (see `AccessPolicy.productAccess`). It is a - * pure value the UI and the gate read, never a client-side switch: - * - * - [betaFree] is true while the product is in its open beta, which makes the - * whole course usable and sells nothing. - * - [plan] is the server's plan for this device. - * - [betaFounder] is the orthogonal founder status the server reports in - * `GET /v1/me`. It represents a permanent lifetime grant for the PRO tier. - * - [lifetimeGrant] is any permanent, non-recurring grant (Beta Founder or lifetime purchase). - * - [sellingEnabled] is true only when the product is out of beta, the plan's - * flag is on and a real price is published. - * - * [Unknown] is the conservative default for a caller that did not resolve the - * config: no beta, FREE. - */ -data class ProductAccess( - val betaFree: Boolean = false, - val plan: ProductPlan = ProductPlan.FREE, - val sellingEnabled: Boolean = false, - val betaFounder: Boolean = false, - val lifetimeGrant: EntitlementGrant? = if (betaFounder) EntitlementGrant( - plan = ProductPlan.PRO, - kind = EntitlementKind.LIFETIME, - source = "beta_founder", - label = "Beta Founder", - ) else null, -) { - /** - * The plan the learner actually enjoys: - * - During the open beta, everything is open (PERSONALIZED tier, all content, all features). - * - Outside beta, a lifetime grant (like Beta Founder) gives permanent PRO content access for life - * without PERSONALIZED. If the user contracts PERSONALIZED, they reach that top tier. - * - Regular users get the plan reported by the server. - */ - val effectivePlan: ProductPlan - get() = when { - betaFree -> ProductPlan.PERSONALIZED - plan == ProductPlan.PERSONALIZED -> ProductPlan.PERSONALIZED - lifetimeGrant != null -> lifetimeGrant.plan - else -> plan - } - - /** True while the learner may use every content surface of the app. */ - val entitled: Boolean get() = betaFree || effectivePlan != ProductPlan.FREE - - /** - * True when the learner reaches the personalization tier. The open beta - * opens everything; outside beta only the PERSONALIZED plan does. A founder - * lands on PRO, so this stays false for them unless they have PERSONALIZED. - */ - val personalized: Boolean get() = betaFree || effectivePlan == ProductPlan.PERSONALIZED - - companion object { - val Unknown = ProductAccess() - } -} - -/** - * The client version policy read from `client` in the config. During the beta it - * only ever warns: [belowMinimum] and [outdated] are meant for a soft notice, and - * nothing in the app blocks on them. A version of 0 (the server did not say) is - * ignored. - */ -data class VersionNotice( - val currentVersion: Int, - val recommendedVersion: Int, - val minimumSupportedVersion: Int, -) { - /** A newer build is recommended, but this one keeps working. */ - val outdated: Boolean get() = recommendedVersion > 0 && currentVersion < recommendedVersion - - /** Older than the minimum the server supports; still only a warning in beta. */ - val belowMinimum: Boolean get() = minimumSupportedVersion > 0 && currentVersion < minimumSupportedVersion - - /** True when there is anything worth telling the learner. */ - val visible: Boolean get() = outdated || belowMinimum - - companion object { - val None = VersionNotice(0, 0, 0) - } -} - - diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionPlan.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionPlan.kt deleted file mode 100644 index 1f154a0..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionPlan.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.glosso.app.billing - -/** - * One subscription offer as Play returned it, already reduced to what the paywall - * needs. Nothing here is hardcoded: the price and the free-trial period come from - * Play's [ProductDetails] and its pricing phases, so the copy can never drift from - * the store. The wording of the periods is built at the UI from these ISO values, - * so it follows the interface language. - * - * [offerToken] identifies the exact base plan plus offer (including the free-trial - * pricing phase). The purchase flow must use it, or Play would charge the plain - * base plan with no trial. - */ -data class SubscriptionPlan( - val productId: String, - val title: String, - val description: String, - val offerToken: String, - val basePlanId: String, - val offerId: String?, - /** Formatted recurring price, e.g. "6,99 €" (Play's localized string). */ - val priceFormatted: String, - /** ISO-8601 period of the recurring charge, e.g. "P1M", when known. */ - val recurringPeriodIso: String? = null, - /** ISO-8601 trial period, e.g. "P1M", or null when the offer has no trial. */ - val freeTrialPeriod: String?, -) { - val hasFreeTrial: Boolean get() = freeTrialPeriod != null - - /** The end instant of a trial that started at [purchaseTimeMillis]. */ - fun trialEndMillis(purchaseTimeMillis: Long): Long? = - BillingPeriods.endMillis(purchaseTimeMillis, freeTrialPeriod) -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionRepository.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionRepository.kt deleted file mode 100644 index 59ccb37..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionRepository.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.platform.PlatformActivity -import kotlinx.coroutines.flow.StateFlow - -/** - * The subscription port the app depends on. `PlaySubscriptionRepository` (Android) - * is the real Google Play Billing implementation; a fake in the debug source set stands - * in for development and tests, because Google ships no official test double. - * - * Everything is exposed as state the UI can observe; the implementation owns the - * BillingClient, its connection lifecycle and its error handling, so no screen - * ever talks to the store directly. - */ -interface SubscriptionRepository { - - /** The current entitlement state. Never null; starts at NONE. */ - val status: StateFlow - - /** The offer Play returned, or null until product details load. */ - val plan: StateFlow - - /** True while details or purchases are being fetched. */ - val loading: StateFlow - - /** A user-facing error, or null. A failure never crashes the app. */ - val error: StateFlow - - /** - * Connects the client, loads the offer and restores any existing purchase. - * Safe to call more than once; the second call is a no-op while connected. - */ - fun start() - - /** Re-queries product details and purchases. [restore] also restores. */ - fun refresh(restore: Boolean = true) - - /** Launches the store's purchase flow for the loaded offer, from [activity]. */ - fun subscribe(activity: PlatformActivity) - - /** Clears the last error so the UI can dismiss it. */ - fun clearError() -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionStatus.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionStatus.kt deleted file mode 100644 index 3a4832a..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/SubscriptionStatus.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.glosso.app.billing - -/** - * The subscription state the rest of the app reads. It is a pure value so the UI - * and the access policy never touch BillingClient directly. - * - * Entitlement rule, in one place: [isEntitled] is true while the learner still - * has access. CANCELED stays entitled on purpose: canceling stops the renewal but - * Play keeps the purchase until the paid period ends, so the learner keeps what - * they paid for. When Play finally drops the purchase, the repository reports - * NONE. GRACE is set by server verification (RTDN) or the fake; the client cannot - * see a grace period on its own. - */ -enum class SubscriptionStatus { - /** No purchase on this account. */ - NONE, - - /** Free trial running. */ - TRIAL, - - /** Paid subscription renewing. */ - ACTIVE, - - /** Payment failed; still within the grace period, access continues. */ - GRACE, - - /** Canceled by the user; access continues until the period ends. */ - CANCELED, - - /** Deferred payment (e.g. cash); access is granted only once it completes. */ - PENDING, - ; - - /** True when the learner should be treated as Pro right now. */ - val isEntitled: Boolean - get() = this == TRIAL || this == ACTIVE || this == GRACE || this == CANCELED -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/billing/TodayPlan.kt b/shared/src/commonMain/kotlin/com/glosso/app/billing/TodayPlan.kt deleted file mode 100644 index 35bd053..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/billing/TodayPlan.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.engine.model.LessonModel - -/** - * What "Hoy" offers: the lesson to continue (when one is in progress and not - * finished) or the next unfinished lesson in teaching order, plus whether opening it needs - * the paywall. Today and its CTA share this one decision, so the home can never - * point at a lesson the lesson list would have gated - and, crucially, so Today's - * own entry point goes through [AccessPolicy] exactly like the list does. - */ -data class TodayPlan( - val lesson: LessonModel?, - /** True when [lesson] is the lesson already open, so the CTA says "Continue". */ - val resumable: Boolean, - /** True when opening [lesson] must show the paywall instead of the lesson. */ - val locked: Boolean, -) - -/** - * Resolves the Today target. [resumeLessonId] is only resumed while - * [resumeInProgress] is true (a lesson at SUMMARY is done); otherwise the first - * lesson in teaching order that is not in [finishedLessonIds] is offered, never - * the first one the server happens to return and never one already finished. [AccessController] already treats an in-progress lesson as unlocked, so - * a Pro lesson the learner started keeps being finishable. - */ -fun todayPlan( - lessons: List, - themeOrder: List, - resumeLessonId: String?, - resumeInProgress: Boolean, - status: SubscriptionStatus, - productAccess: ProductAccess = ProductAccess.Unknown, - finishedLessonIds: Collection = emptyList(), -): TodayPlan { - val access = AccessController(status, resumeLessonId, productAccess) - val inProgress = resumeLessonId?.let { id -> lessons.firstOrNull { it.id == id } } - val resumable = inProgress != null && resumeInProgress - val lesson = if (resumable) inProgress else AccessPolicy.nextLesson(lessons, themeOrder, finishedLessonIds) - return TodayPlan( - lesson = lesson, - resumable = resumable, - locked = lesson != null && access.isLocked(lesson), - ) -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/platform/FilePicker.kt b/shared/src/commonMain/kotlin/com/glosso/app/platform/FilePicker.kt new file mode 100644 index 0000000..f9b97e2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/glosso/app/platform/FilePicker.kt @@ -0,0 +1,17 @@ +package com.glosso.app.platform + +import androidx.compose.runtime.Composable +import okio.Source + +/** A file the learner picked with the system picker; each [open] reads it from the start. */ +fun interface PickedFile { + fun open(): Source +} + +/** + * Launches the system file picker for one or more files and hands them to + * [onPicked] (never with an empty list). No storage permission: on Android it is + * the Storage Access Framework. Null where the platform offers no picker yet. + */ +@Composable +expect fun rememberFilePicker(onPicked: (List) -> Unit): (() -> Unit)? diff --git a/shared/src/commonMain/kotlin/com/glosso/app/platform/LocaleFormat.kt b/shared/src/commonMain/kotlin/com/glosso/app/platform/LocaleFormat.kt index d6dced2..cd5e7c2 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/platform/LocaleFormat.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/platform/LocaleFormat.kt @@ -15,12 +15,6 @@ expect fun formatDecimal(value: Double, decimals: Int, languageTag: String? = nu /** The language of [languageTag] ("fr" for "fr-CA"), or of the default locale. */ expect fun localeLanguage(languageTag: String? = null): String -/** - * The long, localized date of an ISO-8601 instant read in UTC ("December 31, - * 2027", "31 de diciembre de 2027"), or null when [iso] is not one. - */ -expect fun formatLongUtcDate(iso: String): String? - /** * Weekday, day and month of [epochDay] in the order [languageTag] writes them * ("Friday, September 25", "viernes, 25 de septiembre"). diff --git a/shared/src/commonMain/kotlin/com/glosso/app/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/com/glosso/app/platform/PlatformUi.kt index 3bf3e62..6cbc215 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/platform/PlatformUi.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/platform/PlatformUi.kt @@ -21,21 +21,11 @@ expect fun currentUiLanguage(): String @Composable expect fun rememberReduceMotion(): Boolean -/** - * Keeps the screen out of screenshots and the recents preview while composed - * (the paywall), and lifts it when it leaves the composition. - */ -@Composable -expect fun ScreenshotProtection() - /** The things a screen asks the system to do outside the app. */ interface PlatformActions { /** Opens this app's page in the system settings (to grant a denied permission). */ fun openAppSettings() - /** Opens the store's page to manage the subscription [productId]. */ - fun openSubscriptionManagement(productId: String) - /** * True when the system can hold a per-app interface language the app can set * (Android 13+). Elsewhere the app follows the system language and the @@ -61,13 +51,3 @@ expect fun rememberPlatformActions(): PlatformActions /** The language tags the in-app picker offers; "" means "follow the system". */ val APP_LOCALE_TAGS = listOf("", "en", "es", "fr", "de") - -/** - * The screen the app runs in, which a store purchase flow is launched from: the - * Android `Activity`. iOS has no purchase flow during the beta. - */ -expect open class PlatformActivity - -/** The [PlatformActivity] hosting the composition, or null when there is none. */ -@Composable -expect fun currentPlatformActivity(): PlatformActivity? diff --git a/shared/src/commonMain/kotlin/com/glosso/app/product/BetaEndDate.kt b/shared/src/commonMain/kotlin/com/glosso/app/product/BetaEndDate.kt deleted file mode 100644 index 462bf2d..0000000 --- a/shared/src/commonMain/kotlin/com/glosso/app/product/BetaEndDate.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.glosso.app.product - -import com.glosso.app.platform.formatLongUtcDate - -/** - * Formats the beta end date for the UI straight from `beta.endDate` (ISO-8601 - * UTC). It is the only place the date exists as text, so the copy can never - * drift from the server. Formatting is done in UTC on purpose: an end of - * `2026-12-31T23:59:59Z` must read as December 31 everywhere, not slip to the - * next day in a positive timezone. Null in, null out. - */ -fun formatBetaEndDate(iso: String?): String? { - val value = iso?.takeIf { it.isNotBlank() } ?: return null - return formatLongUtcDate(value) -} diff --git a/shared/src/commonMain/kotlin/com/glosso/app/product/ProductConfigRepository.kt b/shared/src/commonMain/kotlin/com/glosso/app/product/ProductConfigRepository.kt index 9ab467f..e16f6c0 100644 --- a/shared/src/commonMain/kotlin/com/glosso/app/product/ProductConfigRepository.kt +++ b/shared/src/commonMain/kotlin/com/glosso/app/product/ProductConfigRepository.kt @@ -4,8 +4,6 @@ import com.glosso.app.GoogleAccountStore import com.glosso.app.api.AccountState import com.glosso.app.api.ProductConfig import com.glosso.app.api.GlossoApi -import com.glosso.app.billing.AccessPolicy -import com.glosso.app.billing.ProductAccess import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -26,15 +24,10 @@ import kotlin.concurrent.Volatile /** * The app's single view of `/v1/config` and `/v1/me`. * - * It is fail-open by design. It starts on [ProductConfig.BetaFallback] (the beta - * is open, nothing is locked, nothing is sold) and only ever moves to what the - * server actually says. When the server cannot be reached it keeps the last good - * body it persisted, so a cold, offline start still knows the beta is on and - * shows the real end date; it never invents a wall or a price. - * - * It refreshes once on start and then periodically, and recomputes the derived - * [access] and [versionNotice] on every change. The server stays the authority: - * the plan, the flags, the prices and the versions all come from here. + * It starts on an empty [ProductConfig] and only ever moves to what the server + * actually says. When the server cannot be reached it keeps the last good body + * it persisted. It refreshes once on start and then periodically. The server + * stays the authority on the flags and the version policy. */ object ProductConfigRepository { @@ -64,15 +57,12 @@ object ProductConfigRepository { @Volatile private var refreshing = false - private val _config = MutableStateFlow(ProductConfig.BetaFallback) + private val _config = MutableStateFlow(ProductConfig()) val config: StateFlow = _config.asStateFlow() private val _account = MutableStateFlow(null) val account: StateFlow = _account.asStateFlow() - private val _access = MutableStateFlow(AccessPolicy.productAccess(ProductConfig.BetaFallback, null)) - val access: StateFlow = _access.asStateFlow() - /** * Reads the cached bodies (if any) and starts the refresh loop. Safe to call * from every entry point: only the first call wins. @@ -109,7 +99,6 @@ object ProductConfigRepository { persist(KEY_ACCOUNT, encodeAccount(fetched)) GoogleAccountStore.reconcile(fetched.linkedAccounts) } - recompute() } finally { refreshing = false } @@ -122,11 +111,6 @@ object ProductConfigRepository { prefs?.getString(KEY_ACCOUNT)?.let { cached -> decodeAccount(cached)?.let { _account.value = it } } - recompute() - } - - private fun recompute() { - _access.value = AccessPolicy.productAccess(_config.value, _account.value) } private fun persist(key: String, value: String) { diff --git a/shared/src/commonMain/kotlin/com/glosso/app/product/VersionNotice.kt b/shared/src/commonMain/kotlin/com/glosso/app/product/VersionNotice.kt new file mode 100644 index 0000000..0b20f25 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/glosso/app/product/VersionNotice.kt @@ -0,0 +1,35 @@ +package com.glosso.app.product + +import com.glosso.app.api.ProductConfig + +/** + * The client version policy read from `client` in the config. It only ever + * warns: [belowMinimum] and [outdated] are meant for a soft notice, and nothing + * in the app blocks on them. A version of 0 (the server did not say) is ignored. + */ +data class VersionNotice( + val currentVersion: Int, + val recommendedVersion: Int, + val minimumSupportedVersion: Int, +) { + /** A newer build is recommended, but this one keeps working. */ + val outdated: Boolean get() = recommendedVersion > 0 && currentVersion < recommendedVersion + + /** Older than the minimum the server supports; still only a warning. */ + val belowMinimum: Boolean get() = minimumSupportedVersion > 0 && currentVersion < minimumSupportedVersion + + /** True when there is anything worth telling the learner. */ + val visible: Boolean get() = outdated || belowMinimum + + companion object { + val None = VersionNotice(0, 0, 0) + + /** The version policy from [config], for a soft, non-blocking notice. */ + fun from(config: ProductConfig, currentVersion: Int): VersionNotice = + VersionNotice( + currentVersion = currentVersion, + recommendedVersion = config.client.recommendedVersion, + minimumSupportedVersion = config.client.minimumSupportedVersion, + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/glosso/app/LocaleFreeLogicTest.kt b/shared/src/commonTest/kotlin/com/glosso/app/LocaleFreeLogicTest.kt index 5e5306c..e2a5421 100644 --- a/shared/src/commonTest/kotlin/com/glosso/app/LocaleFreeLogicTest.kt +++ b/shared/src/commonTest/kotlin/com/glosso/app/LocaleFreeLogicTest.kt @@ -1,35 +1,12 @@ package com.glosso.app -import com.glosso.app.billing.BillingPeriods -import com.glosso.app.billing.PeriodParts -import com.glosso.app.billing.PeriodUnit import com.glosso.app.platform.isoDayOfWeek import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull /** The platform-free pieces the shared UI computes itself, on every target. */ class LocaleFreeLogicTest { - @Test - fun billingPeriodsReadPlayPeriods() { - assertEquals(PeriodParts(1, PeriodUnit.MONTH), BillingPeriods.parts("P1M")) - assertEquals(PeriodParts(1, PeriodUnit.YEAR), BillingPeriods.parts("P1Y")) - assertEquals(PeriodParts(2, PeriodUnit.WEEK), BillingPeriods.parts("P2W")) - assertEquals(PeriodParts(3, PeriodUnit.DAY), BillingPeriods.parts("P3D")) - assertNull(BillingPeriods.parts("PT1H")) - assertNull(BillingPeriods.parts(null)) - } - - @Test - fun aTrialEndsAfterItsCalendarPeriodInUtc() { - // 2024-01-31 + 1 month clamps to 2024-02-29 (leap year), at the same time of day. - val jan31 = 1_706_659_200_000L + 3_600_000L - assertEquals(1_709_164_800_000L + 3_600_000L, BillingPeriods.endMillis(jan31, "P1M")) - assertEquals(31L * 86_400_000L, BillingPeriods.endMillis(0L, "P1M")) - assertNull(BillingPeriods.endMillis(0L, null)) - } - @Test fun isoDayOfWeekCountsFromMonday() { assertEquals(4, isoDayOfWeek(0)) // 1970-01-01, a Thursday diff --git a/shared/src/iosMain/kotlin/com/glosso/app/AccountSignIn.ios.kt b/shared/src/iosMain/kotlin/com/glosso/app/AccountSignIn.ios.kt deleted file mode 100644 index a5e307a..0000000 --- a/shared/src/iosMain/kotlin/com/glosso/app/AccountSignIn.ios.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.glosso.app - -import com.glosso.app.platform.PlatformContext - -actual val accountSignIn: AccountSignIn get() = UnavailableSignIn - -/** - * No sign-in on iOS yet: the option stays hidden. TODO(ios phase 5): Sign in - * with Apple, verified by the server like Google's token today. - */ -private object UnavailableSignIn : AccountSignIn { - override val isConfigured: Boolean = false - - override suspend fun pickAccount(context: PlatformContext, nonce: String): AccountPick = AccountPick.Cancelled -} diff --git a/shared/src/iosMain/kotlin/com/glosso/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/glosso/app/MainViewController.kt index 1cacb7a..4c2c337 100644 --- a/shared/src/iosMain/kotlin/com/glosso/app/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/glosso/app/MainViewController.kt @@ -6,7 +6,6 @@ import coil3.compose.setSingletonImageLoaderFactory import com.glosso.app.api.ApiCache import com.glosso.app.api.AppContentScope import com.glosso.app.api.GlossoApi -import com.glosso.app.billing.NoSubscriptions import com.glosso.app.designsystem.GlossoTheme import com.glosso.app.download.ForegroundDownloader import com.glosso.app.download.WifiOnlyPreference @@ -50,8 +49,7 @@ data class IosBuild( * open), the daily reminder and crash reports ([CrashReporter] not installed); * - phase 5: Sign in with Apple (no Google sign-in on iOS); * - phase 6: analytics (not initialised, so nothing is recorded or sent until the - * catalogue, the policies and the App Store labels cover iOS); - * - StoreKit: [NoSubscriptions], no purchases or prices during the beta. + * catalogue, the policies and the App Store labels cover iOS). */ fun MainViewController(build: IosBuild = IosBuild()): UIViewController { val services = IosHost.start(build) @@ -136,7 +134,6 @@ private object IosHost { onDevice = NoOnDeviceScoring, downloader = downloader, newApi = newApi, - subscriptions = { NoSubscriptions }, ) } diff --git a/shared/src/iosMain/kotlin/com/glosso/app/billing/NoSubscriptions.kt b/shared/src/iosMain/kotlin/com/glosso/app/billing/NoSubscriptions.kt deleted file mode 100644 index ea2c2eb..0000000 --- a/shared/src/iosMain/kotlin/com/glosso/app/billing/NoSubscriptions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.glosso.app.billing - -import com.glosso.app.platform.PlatformActivity -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * No store purchases on iOS during the beta (decision 4 of - * `docs/IOS-KMP-PLAN.md`): access comes from `/v1/config` and `/v1/me` alone and - * the app shows no prices. TODO(StoreKit 2): once the product moves to FREE with - * paid plans. - */ -internal object NoSubscriptions : SubscriptionRepository { - override val status: StateFlow = MutableStateFlow(SubscriptionStatus.NONE) - override val plan: StateFlow = MutableStateFlow(null) - override val loading: StateFlow = MutableStateFlow(false) - override val error: StateFlow = MutableStateFlow(null) - - override fun start() = Unit - override fun refresh(restore: Boolean) = Unit - override fun subscribe(activity: PlatformActivity) = Unit - override fun clearError() = Unit -} diff --git a/shared/src/iosMain/kotlin/com/glosso/app/platform/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/glosso/app/platform/FilePicker.ios.kt new file mode 100644 index 0000000..25c22a1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/glosso/app/platform/FilePicker.ios.kt @@ -0,0 +1,7 @@ +package com.glosso.app.platform + +import androidx.compose.runtime.Composable + +// iOS has no on-device scorer yet (NoOnDeviceScoring), so nothing imports a file. +@Composable +actual fun rememberFilePicker(onPicked: (List) -> Unit): (() -> Unit)? = null diff --git a/shared/src/iosMain/kotlin/com/glosso/app/platform/LocaleFormat.ios.kt b/shared/src/iosMain/kotlin/com/glosso/app/platform/LocaleFormat.ios.kt index 0075125..e49c228 100644 --- a/shared/src/iosMain/kotlin/com/glosso/app/platform/LocaleFormat.ios.kt +++ b/shared/src/iosMain/kotlin/com/glosso/app/platform/LocaleFormat.ios.kt @@ -2,12 +2,9 @@ package com.glosso.app.platform import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import kotlin.time.Instant import platform.Foundation.NSCalendar import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter -import platform.Foundation.NSDateFormatterLongStyle -import platform.Foundation.NSDateFormatterNoStyle import platform.Foundation.NSLocale import platform.Foundation.NSNumber import platform.Foundation.NSNumberFormatter @@ -49,15 +46,6 @@ actual fun formatDecimal(value: Double, decimals: Int, languageTag: String?): St actual fun localeLanguage(languageTag: String?): String = localeOf(languageTag).languageCode -actual fun formatLongUtcDate(iso: String): String? { - val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return null - val epochDay = instant.epochSeconds.floorDiv(86_400L) - return utcFormatter(NSLocale.currentLocale).apply { - dateStyle = NSDateFormatterLongStyle - timeStyle = NSDateFormatterNoStyle - }.stringFromDate(dateOf(epochDay)) -} - actual fun formatWeekdayDayMonth(epochDay: Long, languageTag: String): String { val locale = localeOf(languageTag) val pattern = NSDateFormatter.dateFormatFromTemplate("EEEEdMMMM", 0u, locale) ?: "EEEE d MMMM" diff --git a/shared/src/iosMain/kotlin/com/glosso/app/platform/PlatformUi.ios.kt b/shared/src/iosMain/kotlin/com/glosso/app/platform/PlatformUi.ios.kt index 1216612..06358f4 100644 --- a/shared/src/iosMain/kotlin/com/glosso/app/platform/PlatformUi.ios.kt +++ b/shared/src/iosMain/kotlin/com/glosso/app/platform/PlatformUi.ios.kt @@ -16,20 +16,12 @@ actual fun currentUiLanguage(): String = remember { platformInfo(PlatformContext @Composable actual fun rememberReduceMotion(): Boolean = remember { UIAccessibilityIsReduceMotionEnabled() } -// TODO(iOS phase 4): hide the paywall from the app switcher snapshot. iOS cannot -// block screenshots; there is no paywall on iOS during the beta. -@Composable -actual fun ScreenshotProtection() = Unit - @Composable actual fun rememberPlatformActions(): PlatformActions = remember { IosPlatformActions } private object IosPlatformActions : PlatformActions { override fun openAppSettings() = open(UIApplicationOpenSettingsURLString) - override fun openSubscriptionManagement(productId: String) = - open("https://apps.apple.com/account/subscriptions") - // iOS keeps the per-app language in the Settings app, not in an API the app sets. override val appLocaleSupported: Boolean get() = false @@ -45,9 +37,3 @@ private object IosPlatformActions : PlatformActions { UIApplication.sharedApplication.openURL(target, emptyMap(), null) } } - -/** No purchase flow on iOS during the beta (docs/IOS-KMP-PLAN.md, decision 4). */ -actual open class PlatformActivity - -@Composable -actual fun currentPlatformActivity(): PlatformActivity? = null diff --git a/tools/licensing/collect.py b/tools/licensing/collect.py index 27efe07..8333b65 100644 --- a/tools/licensing/collect.py +++ b/tools/licensing/collect.py @@ -5,12 +5,12 @@ full attribution list has to travel inside the APK. Coverage is the **resolved debug runtime classpath**, not the hand-declared -dependencies: Play Services/Firebase and the rest of the transitive closure end +dependencies: Play Services and the rest of the transitive closure end up in the APK and need attribution too. The classpath lock is produced by the Gradle task `:androidApp:licensesRuntimeClasspath` (kept fresh by `:androidApp:licensesRuntimeClasspathCheck`). For each runtime module this script reads the licence declared in its POM in the Gradle cache; a small curated -catalogue overrides the rows that are special (billing, the fonts, the model, the +catalogue overrides the rows that are special (the fonts, the model, the generated art, ...) and group rules cover artifacts shipped without a POM (Compose publishes Gradle Module Metadata). POMs are matched by local XML name, so namespace-less POMs are attributed too. @@ -25,7 +25,8 @@ licence (GPL/AGPL, non-commercial, share-alike, or anything not in `ALLOWED_LICENCES`). Server/build/test-only artifacts are still catalogued and must be attributed, but their licence only warns, because they never enter the -artifact (see `tools/licensing/runtime-classpath.txt`). +artifact (see `tools/licensing/runtime-classpath-.txt`, one lock per +app flavor; the catalogue covers their union). Usage: ./gradlew :androidApp:licensesRuntimeClasspath # refresh the classpath lock @@ -53,7 +54,9 @@ ASSET = RESOURCE_FILES / "licenses.json" SCHEMA = 2 -RUNTIME_LOCK = ROOT / "tools/licensing/runtime-classpath.txt" +# One lock per app flavor (fdroid, playstore). The catalogue lives in :shared, +# which has no flavors, so it attributes everything either build can ship. +RUNTIME_LOCKS = [ROOT / f"tools/licensing/runtime-classpath-{flavor}.txt" for flavor in ("fdroid", "playstore")] GRADLE_CACHE = Path(os.environ.get("GRADLE_USER_HOME", Path.home() / ".gradle")) / "caches/modules-2/files-2.1" # Privacy policy, one source per app locale. Spanish reuses the working doc; the @@ -356,12 +359,6 @@ def _dep(id, name, author, license, url, usage, distribution): _dep("io.micrometer:micrometer-registry-prometheus", "Micrometer Prometheus registry", "VMware, Inc. / Broadcom", "Apache-2.0", "https://github.com/micrometer-metrics/micrometer", "Server metrics.", "server"), - _dep("com.android.billingclient:billing", "Google Play Billing Library", "Google LLC", - "Android-SDK", "https://developer.android.com/google/play/billing", - "In-app subscriptions. Governed by the Android SDK License, not Apache-2.0.", "app"), - _dep("com.google.android.play:review-ktx", "Google Play In-App Review", "Google LLC", - "Play-Core-ToS", "https://developer.android.com/guide/playcore/in-app-review", - "In-app review prompt. Governed by the Play Core SDK Terms of Service.", "app"), _dep("io.coil-kt:coil-compose", "Coil", "Coil Contributors", "Apache-2.0", "https://github.com/coil-kt/coil", "Image loading in Compose.", "app"), _dep("junit:junit", "JUnit 4", "JUnit", "EPL-1.0", "https://junit.org/junit4/", @@ -519,12 +516,15 @@ def _auto_component(module_id, version=None, scope="app"): def runtime_coordinates(): - if not RUNTIME_LOCK.is_file(): - raise SystemExit( - f"missing {RUNTIME_LOCK.relative_to(ROOT)}. Run:\n" - " ./gradlew :androidApp:licensesRuntimeClasspath" - ) - return [line.strip() for line in RUNTIME_LOCK.read_text(encoding="utf-8").splitlines() if line.strip()] + coordinates = set() + for lock in RUNTIME_LOCKS: + if not lock.is_file(): + raise SystemExit( + f"missing {lock.relative_to(ROOT)}. Run:\n" + " ./gradlew :androidApp:licensesRuntimeClasspath" + ) + coordinates.update(line.strip() for line in lock.read_text(encoding="utf-8").splitlines() if line.strip()) + return sorted(coordinates) def runtime_module_ids(): @@ -704,7 +704,7 @@ def licence_violations(components=None, distributed_ids=None): def _is_blocking(violation): """A violation stops the build when it is shipped, or when it leaves any catalogued entry unattributed. Server/build/test-only copyleft is a warning: - it does not enter the artifact (see tools/licensing/runtime-classpath.txt).""" + it does not enter the artifact (see tools/licensing/runtime-classpath-*.txt).""" if violation["scope"] == "distributed": return True return any(problem.startswith("unattributed") for problem in violation["problems"]) diff --git a/tools/licensing/collect_test.py b/tools/licensing/collect_test.py index e956d1b..586d28b 100644 --- a/tools/licensing/collect_test.py +++ b/tools/licensing/collect_test.py @@ -3,7 +3,7 @@ `shared/src/commonMain/composeResources/files/licenses.json` is the file the app actually serves. The list is built from the **resolved runtime classpath** (see -`tools/licensing/runtime-classpath.txt`), not just the hand-declared +`tools/licensing/runtime-classpath-*.txt`), not just the hand-declared dependencies, so these tests fail if: * any runtime artifact has no entry (unattributed code shipped in the APK), or @@ -45,11 +45,10 @@ def test_every_runtime_artifact_resolves_its_licence(self): unresolved = collect.unresolved_dependencies() self.assertEqual(unresolved, [], f"runtime artifacts with no resolvable licence: {unresolved[:20]}") - def test_transitive_google_runtime_arrives_through_billing(self): - # Regression: play-services/firebase enter through Billing and are shipped. + def test_transitive_google_runtime_is_attributed(self): + # Regression: play-services enter transitively through Google sign-in and are shipped. ids = {c["id"] for c in _asset()["components"]} self.assertTrue(any(i.startswith("com.google.android.gms:play-services") for i in ids)) - self.assertTrue(any(i.startswith("com.google.android.datatransport:") for i in ids)) def test_every_declared_dependency_has_an_entry(self): declared = collect.declared_dependency_ids() @@ -110,8 +109,8 @@ def test_each_font_carries_its_own_ofl_copyright(self): def test_google_sdks_are_not_mislabelled_as_apache(self): by_id = {c["id"]: c for c in _asset()["components"]} - self.assertEqual(by_id["com.android.billingclient:billing"]["license"], "Android-SDK") - self.assertEqual(by_id["com.google.android.play:review-ktx"]["license"], "Play-Core-ToS") + self.assertEqual(by_id["com.google.android.gms:play-services-auth"]["license"], "Android-SDK") + self.assertNotIn("com.android.billingclient:billing", by_id) def test_distributed_media_and_model_are_present(self): ids = {c["id"] for c in _asset()["components"]} diff --git a/tools/licensing/privacy/privacy.de.md b/tools/licensing/privacy/privacy.de.md index 657f497..6d4c38a 100644 --- a/tools/licensing/privacy/privacy.de.md +++ b/tools/licensing/privacy/privacy.de.md @@ -33,7 +33,7 @@ Der Server **speichert die IP nicht** in der Ereignistabelle. Serverereignisse ## Konto: Weiter mit Google (optional) -Es nimmt das Konto (und den Beta Founder Status) auf ein neues Handy mit. Es ist +Es nimmt das Konto auf ein neues Handy mit. Es ist optional; die App funktioniert ohne es genauso. - **Weiter mit Google**: Die Person wählt ein Google-Konto auf dem Handy @@ -86,8 +86,7 @@ optional; die App funktioniert ohne es genauso. beendet, Lektion begonnen, beendet oder verlassen, Übung beendet (Art, 10-Punkte-Bereich der Bewertung, gemeistert oder nicht), auf dem Telefon bewerteter Ausspracheversuch (Bewertungsbereich, gemeistert oder nicht, IDs - der Fehlerregeln), Mikrofon verweigert, Pro-Angebot gesehen, Pro-Schaltfläche - getippt und Kauf begonnen oder fehlgeschlagen. + der Fehlerregeln) und Mikrofon verweigert. Ab App 2.5.4 sendet die App außerdem: diff --git a/tools/licensing/privacy/privacy.en.md b/tools/licensing/privacy/privacy.en.md index 2397982..d81c73d 100644 --- a/tools/licensing/privacy/privacy.en.md +++ b/tools/licensing/privacy/privacy.en.md @@ -32,7 +32,7 @@ The server **does not store the IP** in the events table. Server events ## Account: Continue with Google (optional) -It carries the account (and the Beta Founder status) to a new phone. It is +It carries the account to a new phone. It is optional; the app works the same without it. - **Continue with Google**: the user picks a Google account on the phone @@ -83,9 +83,8 @@ optional; the app works the same without it. - The events the app sends: app opened, session length, onboarding started and finished, lesson started, finished or left, exercise finished (kind, a 10-point score band, mastered or not), pronunciation attempt scored on the - phone (score band, mastered or not, ids of the error rules), microphone - permission denied, paywall seen, Pro button tapped and purchase started or - failed. + phone (score band, mastered or not, ids of the error rules) and microphone + permission denied. From app 2.5.4, the app also sends: diff --git a/tools/licensing/privacy/privacy.fr.md b/tools/licensing/privacy/privacy.fr.md index 7aa1635..8603c4a 100644 --- a/tools/licensing/privacy/privacy.fr.md +++ b/tools/licensing/privacy/privacy.fr.md @@ -34,8 +34,7 @@ sans audio. ## Compte : continuer avec Google (facultatif) -Il permet d'emporter le compte (et le statut Beta Founder) sur un nouveau -téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. +Il permet d'emporter le compte sur un nouveau téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. - **Continuer avec Google** : l'utilisateur choisit un compte Google du téléphone (Android Credential Manager). Seule l'identité est demandée : le @@ -88,8 +87,7 @@ téléphone. Il est facultatif ; l'app fonctionne de la même façon sans lui. commencé et terminé, leçon commencée, terminée ou quittée, exercice terminé (type, tranche de note de 10 points, maîtrisé ou non), essai de prononciation noté sur le téléphone (tranche de note, maîtrisé ou non, identifiants des - règles d'erreur), micro refusé, offre Pro vue, bouton Pro touché et achat - commencé ou échoué. + règles d'erreur) et micro refusé. À partir de l'app 2.5.4, l'app envoie aussi : diff --git a/tools/licensing/runtime-classpath-fdroid.txt b/tools/licensing/runtime-classpath-fdroid.txt new file mode 100644 index 0000000..4c6c6a8 --- /dev/null +++ b/tools/licensing/runtime-classpath-fdroid.txt @@ -0,0 +1,206 @@ +androidx.activity:activity-compose:1.9.3 +androidx.activity:activity-ktx:1.9.3 +androidx.activity:activity:1.9.3 +androidx.annotation:annotation-experimental:1.4.1 +androidx.annotation:annotation-jvm:1.10.0 +androidx.annotation:annotation:1.10.0 +androidx.appcompat:appcompat-resources:1.7.1 +androidx.arch.core:core-common:2.2.0 +androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.0.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-android:1.11.2 +androidx.compose.animation:animation-core-android:1.11.2 +androidx.compose.animation:animation-core:1.11.2 +androidx.compose.animation:animation:1.11.2 +androidx.compose.foundation:foundation-android:1.11.2 +androidx.compose.foundation:foundation-layout-android:1.11.2 +androidx.compose.foundation:foundation-layout:1.11.2 +androidx.compose.foundation:foundation:1.11.2 +androidx.compose.material3:material3-android:1.4.0 +androidx.compose.material3:material3:1.4.0 +androidx.compose.material:material-icons-core-android:1.7.8 +androidx.compose.material:material-icons-core:1.7.8 +androidx.compose.material:material-icons-extended-android:1.7.8 +androidx.compose.material:material-icons-extended:1.7.8 +androidx.compose.material:material-ripple-android:1.11.2 +androidx.compose.material:material-ripple:1.11.2 +androidx.compose.runtime:runtime-android:1.11.2 +androidx.compose.runtime:runtime-annotation-android:1.11.2 +androidx.compose.runtime:runtime-annotation:1.11.2 +androidx.compose.runtime:runtime-retain-android:1.11.2 +androidx.compose.runtime:runtime-retain:1.11.2 +androidx.compose.runtime:runtime-saveable-android:1.11.2 +androidx.compose.runtime:runtime-saveable:1.11.2 +androidx.compose.runtime:runtime:1.11.2 +androidx.compose.ui:ui-android:1.11.2 +androidx.compose.ui:ui-geometry-android:1.11.2 +androidx.compose.ui:ui-geometry:1.11.2 +androidx.compose.ui:ui-graphics-android:1.11.2 +androidx.compose.ui:ui-graphics:1.11.2 +androidx.compose.ui:ui-test-manifest:1.11.2 +androidx.compose.ui:ui-text-android:1.11.2 +androidx.compose.ui:ui-text:1.11.2 +androidx.compose.ui:ui-tooling-android:1.11.2 +androidx.compose.ui:ui-tooling-data-android:1.11.2 +androidx.compose.ui:ui-tooling-data:1.11.2 +androidx.compose.ui:ui-tooling-preview-android:1.11.2 +androidx.compose.ui:ui-tooling-preview:1.11.2 +androidx.compose.ui:ui-tooling:1.11.2 +androidx.compose.ui:ui-unit-android:1.11.2 +androidx.compose.ui:ui-unit:1.11.2 +androidx.compose.ui:ui-util-android:1.11.2 +androidx.compose.ui:ui-util:1.11.2 +androidx.compose.ui:ui:1.11.2 +androidx.compose:compose-bom:2026.05.01 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 +androidx.customview:customview-poolingcontainer:1.0.0 +androidx.documentfile:documentfile:1.0.0 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.4.0 +androidx.exifinterface:exifinterface:1.4.2 +androidx.graphics:graphics-path:1.0.1 +androidx.interpolator:interpolator:1.0.0 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.9.4 +androidx.lifecycle:lifecycle-common-jvm:2.9.4 +androidx.lifecycle:lifecycle-common:2.9.4 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4 +androidx.lifecycle:lifecycle-livedata-core:2.9.4 +androidx.lifecycle:lifecycle-livedata:2.9.4 +androidx.lifecycle:lifecycle-process:2.9.4 +androidx.lifecycle:lifecycle-runtime-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-compose:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4 +androidx.lifecycle:lifecycle-runtime:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4 +androidx.lifecycle:lifecycle-viewmodel:2.9.4 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.1 +androidx.savedstate:savedstate-android:1.4.0 +androidx.savedstate:savedstate-compose-android:1.4.0 +androidx.savedstate:savedstate-compose:1.4.0 +androidx.savedstate:savedstate-ktx:1.4.0 +androidx.savedstate:savedstate:1.4.0 +androidx.startup:startup-runtime:1.1.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 +androidx.vectordrawable:vectordrawable-animated:1.1.0 +androidx.vectordrawable:vectordrawable:1.1.0 +androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 +com.caverock:androidsvg-aar:1.4 +com.google.accompanist:accompanist-drawablepainter:0.37.3 +com.google.guava:listenablefuture:1.0 +com.microsoft.onnxruntime:onnxruntime-android:1.30.0 +com.squareup.okhttp3:okhttp:4.12.0 +com.squareup.okio:okio-jvm:3.17.0 +com.squareup.okio:okio:3.17.0 +io.coil-kt.coil3:coil-android:3.5.0 +io.coil-kt.coil3:coil-compose-android:3.5.0 +io.coil-kt.coil3:coil-compose-core-android:3.5.0 +io.coil-kt.coil3:coil-compose-core:3.5.0 +io.coil-kt.coil3:coil-compose:3.5.0 +io.coil-kt.coil3:coil-core-android:3.5.0 +io.coil-kt.coil3:coil-core:3.5.0 +io.coil-kt.coil3:coil-network-core-android:3.5.0 +io.coil-kt.coil3:coil-network-core:3.5.0 +io.coil-kt.coil3:coil-network-okhttp-android:3.5.0 +io.coil-kt.coil3:coil-network-okhttp:3.5.0 +io.coil-kt.coil3:coil-svg-android:3.5.0 +io.coil-kt.coil3:coil-svg:3.5.0 +io.coil-kt.coil3:coil:3.5.0 +io.ktor:ktor-client-android-jvm:3.0.3 +io.ktor:ktor-client-android:3.0.3 +io.ktor:ktor-client-content-negotiation-jvm:3.0.3 +io.ktor:ktor-client-content-negotiation:3.0.3 +io.ktor:ktor-client-core-jvm:3.0.3 +io.ktor:ktor-client-core:3.0.3 +io.ktor:ktor-events-jvm:3.0.3 +io.ktor:ktor-events:3.0.3 +io.ktor:ktor-http-jvm:3.0.3 +io.ktor:ktor-http:3.0.3 +io.ktor:ktor-io-jvm:3.0.3 +io.ktor:ktor-io:3.0.3 +io.ktor:ktor-serialization-jvm:3.0.3 +io.ktor:ktor-serialization-kotlinx-json-jvm:3.0.3 +io.ktor:ktor-serialization-kotlinx-json:3.0.3 +io.ktor:ktor-serialization-kotlinx-jvm:3.0.3 +io.ktor:ktor-serialization-kotlinx:3.0.3 +io.ktor:ktor-serialization:3.0.3 +io.ktor:ktor-sse-jvm:3.0.3 +io.ktor:ktor-sse:3.0.3 +io.ktor:ktor-utils-jvm:3.0.3 +io.ktor:ktor-utils:3.0.3 +io.ktor:ktor-websocket-serialization-jvm:3.0.3 +io.ktor:ktor-websocket-serialization:3.0.3 +io.ktor:ktor-websockets-jvm:3.0.3 +io.ktor:ktor-websockets:3.0.3 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.animation:animation-core:1.11.1 +org.jetbrains.compose.animation:animation:1.11.1 +org.jetbrains.compose.annotation-internal:annotation:1.9.1 +org.jetbrains.compose.collection-internal:collection:1.9.1 +org.jetbrains.compose.components:components-resources-android:1.11.1 +org.jetbrains.compose.components:components-resources:1.11.1 +org.jetbrains.compose.foundation:foundation-layout:1.11.1 +org.jetbrains.compose.foundation:foundation:1.11.1 +org.jetbrains.compose.material3:material3:1.9.0 +org.jetbrains.compose.material:material-icons-core:1.7.3 +org.jetbrains.compose.material:material-icons-extended:1.7.3 +org.jetbrains.compose.material:material-ripple:1.9.1 +org.jetbrains.compose.runtime:runtime-saveable:1.11.1 +org.jetbrains.compose.runtime:runtime:1.11.1 +org.jetbrains.compose.ui:ui-backhandler-android:1.11.1 +org.jetbrains.compose.ui:ui-backhandler:1.11.1 +org.jetbrains.compose.ui:ui-geometry:1.11.1 +org.jetbrains.compose.ui:ui-graphics:1.11.1 +org.jetbrains.compose.ui:ui-text:1.11.1 +org.jetbrains.compose.ui:ui-unit:1.11.1 +org.jetbrains.compose.ui:ui-util:1.11.1 +org.jetbrains.compose.ui:ui:1.11.1 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21 +org.jetbrains.kotlin:kotlin-stdlib:2.4.20 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2 +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.5.4 +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.5.4 +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.5.4 +org.jetbrains.kotlinx:kotlinx-io-core:0.5.4 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 +org.jetbrains:annotations:23.0.0 +org.jspecify:jspecify:1.0.0 +org.slf4j:slf4j-api:2.0.16 diff --git a/tools/licensing/runtime-classpath.txt b/tools/licensing/runtime-classpath-playstore.txt similarity index 94% rename from tools/licensing/runtime-classpath.txt rename to tools/licensing/runtime-classpath-playstore.txt index 11a41aa..e647fe4 100644 --- a/tools/licensing/runtime-classpath.txt +++ b/tools/licensing/runtime-classpath-playstore.txt @@ -117,27 +117,18 @@ androidx.viewpager:viewpager:1.0.0 androidx.window:window-core-android:1.5.0 androidx.window:window-core:1.5.0 androidx.window:window:1.5.0 -com.android.billingclient:billing:9.1.0 com.caverock:androidsvg-aar:1.4 com.google.accompanist:accompanist-drawablepainter:0.37.3 -com.google.android.datatransport:transport-api:3.0.0 -com.google.android.datatransport:transport-backend-cct:3.1.8 -com.google.android.datatransport:transport-runtime:3.1.8 com.google.android.gms:play-services-auth-api-phone:18.0.2 com.google.android.gms:play-services-auth-base:18.0.10 com.google.android.gms:play-services-auth-blockstore:16.4.0 com.google.android.gms:play-services-auth:21.1.1 com.google.android.gms:play-services-base:18.5.0 -com.google.android.gms:play-services-basement:18.9.0 +com.google.android.gms:play-services-basement:18.4.0 com.google.android.gms:play-services-fido:21.0.0 com.google.android.gms:play-services-identity-credentials:16.0.0-alpha02 -com.google.android.gms:play-services-location:19.0.0 -com.google.android.gms:play-services-places-placereport:17.0.0 com.google.android.gms:play-services-tasks:18.2.0 com.google.android.libraries.identity.googleid:googleid:1.1.1 -com.google.firebase:firebase-encoders-json:18.0.0 -com.google.firebase:firebase-encoders-proto:16.0.0 -com.google.firebase:firebase-encoders:17.0.0 com.google.guava:listenablefuture:1.0 com.microsoft.onnxruntime:onnxruntime-android:1.30.0 com.squareup.okhttp3:okhttp:4.12.0 @@ -183,7 +174,6 @@ io.ktor:ktor-websocket-serialization-jvm:3.0.3 io.ktor:ktor-websocket-serialization:3.0.3 io.ktor:ktor-websockets-jvm:3.0.3 io.ktor:ktor-websockets:3.0.3 -javax.inject:javax.inject:1 org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 diff --git a/tools/score-parity/run-parity-test.sh b/tools/score-parity/run-parity-test.sh index 9de2606..61de5f1 100755 --- a/tools/score-parity/run-parity-test.sh +++ b/tools/score-parity/run-parity-test.sh @@ -67,5 +67,5 @@ if [[ "$CLEAR" == 1 ]]; then fi echo "Running ScoreParityTest on $SERIAL" -ANDROID_SERIAL="$SERIAL" "$ROOT/gradlew" -p "$ROOT" :androidApp:connectedDebugAndroidTest \ +ANDROID_SERIAL="$SERIAL" "$ROOT/gradlew" -p "$ROOT" :androidApp:connectedPlaystoreDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.class=com.glosso.app.ScoreParityTest