From 4548a782e9b4fc674df428c2230a512bfc092304 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 05:27:21 -0500 Subject: [PATCH 01/11] feat: prepare Play release and require verified native purchases --- .gitignore | 10 + android/app/build.gradle | 20 +- android/app/src/main/AndroidManifest.xml | 6 +- .../java/com/lilopia/game/MainActivity.java | 19 +- .../main/java/com/lilopia/game/PlayStore.java | 147 +++++++++ android/build.gradle | 2 +- server/package-lock.json | 308 ++++++++++++++++++ server/package.json | 8 + server/server.cjs | 38 +++ server/verify.cjs | 18 + src/game.js | 7 +- src/logic.js | 8 +- src/ui.js | 40 ++- tests/release.test.js | 24 ++ tests/verification.test.js | 45 +++ 15 files changed, 673 insertions(+), 27 deletions(-) create mode 100644 android/app/src/main/java/com/lilopia/game/PlayStore.java create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/server.cjs create mode 100644 server/verify.cjs create mode 100644 tests/release.test.js create mode 100644 tests/verification.test.js diff --git a/.gitignore b/.gitignore index 1eae0cf..7f52a20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ dist/ node_modules/ +android/.gradle/ +android/**/build/ +android/local.properties +android/keystore.properties +*.jks +*.keystore +*.p12 +.env* +*.db +*.db-* diff --git a/android/app/build.gradle b/android/app/build.gradle index 35fd579..ed9b20b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -29,14 +29,19 @@ if (ksFile.exists()) ksFile.withInputStream { ks.load(it) } android { namespace 'com.lilopia.game' - compileSdk 35 + compileSdk 36 defaultConfig { applicationId 'com.lilopia.game' minSdk 24 - targetSdk 35 - versionCode 1 - versionName '0.1.0-prototype' + targetSdk 36 + versionCode (findProperty('versionCode') ?: '2').toInteger() + versionName '1.0.0' + def verifier = providers.gradleProperty('verificationUrl').getOrElse('') + if (verifier && !(verifier ==~ /https:\/\/[a-zA-Z0-9.-]+(?::443)?\/[^\s"\\]*/)) { + throw new GradleException('verificationUrl must be an HTTPS endpoint without quotes or escapes') + } + buildConfigField 'String', 'VERIFICATION_URL', '"' + verifier + '"' } signingConfigs { @@ -53,12 +58,12 @@ android { buildTypes { release { minifyEnabled false - // Without a keystore the release APK is signed with the debug key: fine for - // installing the prototype on test phones, not accepted by Google Play. - signingConfig ks['storeFile'] ? signingConfigs.release : signingConfigs.debug + // No debug fallback. Without an upload key, bundleRelease produces an unsigned AAB. + if (ks['storeFile']) signingConfig signingConfigs.release } } + buildFeatures { buildConfig true } sourceSets { main.assets.srcDir gameAssets } @@ -73,4 +78,5 @@ tasks.named('preBuild') { dependsOn copyGame } dependencies { implementation 'androidx.webkit:webkit:1.12.1' + implementation 'com.android.billingclient:billing:8.0.0' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 05c0807..cc77426 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ - + + PRODUCTS = new HashSet<>(Arrays.asList( + "com.lilopia.expansion.dream_mansion", "com.lilopia.expansion.beach_vacation", + "com.lilopia.expansion.city_life")); + private final Activity activity; + private final WebView web; + private final BillingClient client; + private final Map details = new ConcurrentHashMap<>(); + private final Set owned = ConcurrentHashMap.newKeySet(); + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + private volatile boolean closed; + + PlayStore(Activity activity, WebView web) { + this.activity = activity; this.web = web; + client = BillingClient.newBuilder(activity).setListener(this) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .enableAutoServiceReconnection().build(); + // No verifier configured: do not offer a payment that cannot be fulfilled. + if (!BuildConfig.VERIFICATION_URL.isEmpty()) client.startConnection(new BillingClientStateListener() { + public void onBillingSetupFinished(BillingResult result) { + if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) { catalog(); refresh(); } + } + public void onBillingServiceDisconnected() { details.clear(); notifyWeb("Store disconnected. Please try again."); } + }); + } + @JavascriptInterface public boolean owns(String id) { return owned.contains(id); } + @JavascriptInterface public String price(String id) { + ProductDetails d = details.get(id); + return d != null && d.getOneTimePurchaseOfferDetails() != null + ? d.getOneTimePurchaseOfferDetails().getFormattedPrice() : ""; + } + @JavascriptInterface public boolean available(String id) { + return !closed && client.isReady() && details.containsKey(id) && !BuildConfig.VERIFICATION_URL.isEmpty(); + } + @JavascriptInterface public void buy(String id) { + if (!PRODUCTS.contains(id) || !available(id)) { notifyWeb("Purchases are unavailable. No payment was started."); return; } + activity.runOnUiThread(() -> { + // Native parent gate prevents web code from launching a payment directly. + int a = 12 + new java.security.SecureRandom().nextInt(8), b = 7; + EditText answer = new EditText(activity); answer.setInputType(InputType.TYPE_CLASS_NUMBER); + new AlertDialog.Builder(activity).setTitle("Grown-ups only: " + a + " × " + b + " = ?") + .setView(answer).setNegativeButton("Cancel", null).setPositiveButton("Continue", (dialog, which) -> { + if (!String.valueOf(a * b).equals(answer.getText().toString().trim())) return; + ProductDetails d = details.get(id); + if (d == null || !client.isReady()) { notifyWeb("Store unavailable. Please try again."); return; } + BillingFlowParams.ProductDetailsParams product = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(d).build(); + BillingResult result = client.launchBillingFlow(activity, BillingFlowParams.newBuilder() + .setProductDetailsParamsList(Collections.singletonList(product)).build()); + if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) notifyWeb("Payment could not start. Please try again."); + }).show(); + }); + } + @JavascriptInterface public void restore() { activity.runOnUiThread(this::refresh); } + private void catalog() { + List products = new ArrayList<>(); + for (String id : PRODUCTS) products.add(QueryProductDetailsParams.Product.newBuilder() + .setProductId(id).setProductType(BillingClient.ProductType.INAPP).build()); + client.queryProductDetailsAsync(QueryProductDetailsParams.newBuilder().setProductList(products).build(), (result, response) -> { + details.clear(); + if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) + for (ProductDetails d : response.getProductDetailsList()) details.put(d.getProductId(), d); + notifyWeb(""); + }); + } + void refresh() { + if (!client.isReady()) { notifyWeb("Store is unavailable. Connect to Google Play and try again."); return; } + client.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(), + (result, purchases) -> { + if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) { notifyWeb("Could not restore purchases. Please try again."); return; } + // Serial verification avoids an older query overwriting a newer purchase result. + worker.execute(() -> { + Set verified = new HashSet<>(); + for (Purchase p : purchases) verified.addAll(verify(p)); + owned.clear(); owned.addAll(verified); + notifyWeb("Purchase check complete."); + }); + }); + } + @Override public void onPurchasesUpdated(BillingResult result, List purchases) { + if (result.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) { + worker.execute(() -> { + for (Purchase p : purchases) owned.addAll(verify(p)); + notifyWeb("Purchase check complete."); + }); + } else if (result.getResponseCode() == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) refresh(); + else notifyWeb(result.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED + ? "Purchase canceled." : "Purchase unavailable. Please try again."); + } + private Set verify(Purchase purchase) { + Set verified = new HashSet<>(); + if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) { + notifyWeb("Payment is pending. Packs stay locked until payment is complete."); return verified; + } + for (String id : purchase.getProducts()) { + if (!PRODUCTS.contains(id)) continue; + HttpsURLConnection connection = null; + try { + JSONObject body = new JSONObject().put("productId", id).put("purchaseToken", purchase.getPurchaseToken()); + connection = (HttpsURLConnection) new URL(BuildConfig.VERIFICATION_URL).openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(15000); connection.setReadTimeout(15000); + connection.setRequestMethod("POST"); connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (java.io.OutputStream out = connection.getOutputStream()) { out.write(body.toString().getBytes(StandardCharsets.UTF_8)); } + if (connection.getResponseCode() != 200) throw new IllegalStateException("Verification rejected"); + byte[] bytes; + try (java.io.InputStream input = connection.getInputStream(); java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream()) { + byte[] chunk = new byte[1024]; int count; + while ((count = input.read(chunk)) != -1) { + buffer.write(chunk, 0, count); + if (buffer.size() > 8192) throw new IllegalStateException("Invalid verification response"); + } + bytes = buffer.toByteArray(); + } + if (bytes.length > 8192) throw new IllegalStateException("Invalid verification response"); + JSONObject response = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); + if (response.optBoolean("verified") && id.equals(response.optString("productId"))) verified.add(id); + } catch (Exception e) { notifyWeb("Could not verify payment. Connect and use Restore purchases; do not buy again."); } + finally { if (connection != null) connection.disconnect(); } + } + return verified; + } + private void notifyWeb(String message) { + activity.runOnUiThread(() -> { + if (!closed) web.evaluateJavascript("window.lilopiaStoreChanged && window.lilopiaStoreChanged(" + JSONObject.quote(message) + ")", null); + }); + } + void close() { closed = true; client.endConnection(); worker.shutdownNow(); } +} diff --git a/android/build.gradle b/android/build.gradle index 5b7ccba..9e42cfd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,3 +1,3 @@ plugins { - id 'com.android.application' version '8.7.3' apply false + id 'com.android.application' version '8.10.1' apply false } diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..0450a09 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,308 @@ +{ + "name": "lilopia-play-verification", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lilopia-play-verification", + "version": "1.0.0", + "dependencies": { + "google-auth-library": "^10.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f9af6ac --- /dev/null +++ b/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "lilopia-play-verification", + "version": "1.0.0", + "private": true, + "engines": { "node": ">=24" }, + "scripts": { "start": "node server.cjs" }, + "dependencies": { "google-auth-library": "^10.0.0" } +} diff --git a/server/server.cjs b/server/server.cjs new file mode 100644 index 0000000..b03a1c6 --- /dev/null +++ b/server/server.cjs @@ -0,0 +1,38 @@ +'use strict'; +const http = require('node:http'); +const { DatabaseSync } = require('node:sqlite'); +const { GoogleAuth } = require('google-auth-library'); +const { verifyPurchase } = require('./verify.cjs'); +const db = new DatabaseSync(process.env.DATABASE_PATH || 'purchases.db'); +db.exec('PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS purchases (token_hash TEXT PRIMARY KEY, product_id TEXT NOT NULL, checked_at INTEGER NOT NULL)'); +const upsert = db.prepare('INSERT INTO purchases VALUES (?, ?, ?) ON CONFLICT(token_hash) DO UPDATE SET checked_at=excluded.checked_at WHERE purchases.product_id=excluded.product_id'); +const find = db.prepare('SELECT product_id FROM purchases WHERE token_hash=?'); +const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/androidpublisher'] }); +const endpoint = (pkg, id, token) => 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/' + + encodeURIComponent(pkg) + '/purchases/products/' + encodeURIComponent(id) + '/tokens/' + encodeURIComponent(token); +const google = { + get: async (...args) => (await auth.request({ url: endpoint(...args), timeout: 15000 })).data, + acknowledge: async (...args) => auth.request({ url: endpoint(...args) + ':acknowledge', method: 'POST', data: {}, timeout: 15000 }) +}; +const limits = new Map(); +setInterval(() => limits.clear(), 60000).unref(); +http.createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store'); + if (req.url !== '/verify' || req.method !== 'POST') { res.writeHead(404); res.end('{}'); return; } + const ip = req.socket.remoteAddress; const count = (limits.get(ip) || 0) + 1; limits.set(ip, count); + if (count > 60) { res.writeHead(429); res.end('{}'); return; } + try { + if (!String(req.headers['content-type']).startsWith('application/json')) throw Error('Invalid type'); + const parts = []; let size = 0; + for await (const part of req) { size += part.length; if (size > 8192) throw Error('Too large'); parts.push(part); } + const result = await verifyPurchase(JSON.parse(Buffer.concat(parts).toString('utf8')), google, async (hash, id) => { + const existing = find.get(hash); + if (existing && existing.product_id !== id) throw Error('Token reused with another product'); + upsert.run(hash, id, Date.now()); + }); + res.end(JSON.stringify(result)); + } catch (_) { + // Never log request bodies, purchase tokens, Google errors with token URLs, or credentials. + res.writeHead(503); res.end('{"verified":false,"message":"Purchase could not be verified. Retry restore later."}'); + } +}).listen(Number(process.env.PORT || 8080), '0.0.0.0', () => console.log('LILOPIA purchase verifier listening')); diff --git a/server/verify.cjs b/server/verify.cjs new file mode 100644 index 0000000..655ea27 --- /dev/null +++ b/server/verify.cjs @@ -0,0 +1,18 @@ +'use strict'; +const { createHash } = require('node:crypto'); +const PACKAGE = 'com.lilopia.game'; +const PRODUCTS = new Set(['dream_mansion', 'beach_vacation', 'city_life'].map(s => 'com.lilopia.expansion.' + s)); +async function verifyPurchase(body, google, persist) { + if (!body || !PRODUCTS.has(body.productId) || typeof body.purchaseToken !== 'string' + || body.purchaseToken.length < 10 || body.purchaseToken.length > 4096) throw Error('Invalid request'); + const { productId, purchaseToken } = body; + // Package and product are bound by this Developer API endpoint, never supplied by the client. + const purchase = await google.get(PACKAGE, productId, purchaseToken); + if (purchase.purchaseState !== 0 || (purchase.consumptionState ?? 0) !== 0 + || (purchase.quantity ?? 1) !== 1) throw Error('Not an active non-consumable purchase'); + const hash = createHash('sha256').update(purchaseToken).digest('hex'); + await persist(hash, productId); + if (purchase.acknowledgementState !== 1) await google.acknowledge(PACKAGE, productId, purchaseToken); + return { verified: true, productId }; +} +module.exports = { verifyPurchase, PACKAGE, PRODUCTS }; diff --git a/src/game.js b/src/game.js index be4e6a6..6e748d1 100644 --- a/src/game.js +++ b/src/game.js @@ -107,6 +107,11 @@ function act(ch, a, dur = 1.8) { ch._action = a; ch._actionT = T; ch._actionDur /* ================= lifecycle ================= */ G.start = state => { + if (globalThis.LilopiaAndroid) { + state.owned = {}; // Web save data is never evidence of a Google Play purchase. + const location = L.LOCATIONS[state.scene.split('/')[0]]; + if (location && location.product && !logic.isOwned(state, location.product)) state.scene = 'home/living'; + } S = state; prepAll(); logic.catchUp(S, Date.now()); G.selected = null; G.selItem = null; hist = {}; L.audio.setVolumes(S.settings.sfx, S.settings.music); L.reducedMotion = !!S.settings.reducedMotion || matchMedia('(prefers-reduced-motion: reduce)').matches; bgKey = ''; emit('scene'); emit('coins'); emit('bag'); @@ -540,7 +545,7 @@ G.rehomePet = p => { for (const c of S.chars) if (c._hold && c._hold.pet === p) G.bringPet = p => { p.scene = S.scene; p.x = rand(.3, .7); p.z = rand(.82, .95); p._use = null; p._born = T; p._heldBy = null; sfx('pet'); save(); }; G.setTod = tod => { S.tod = tod; bgKey = ''; save(); }; G.setSettings = s => { Object.assign(S.settings, s); L.audio.setVolumes(S.settings.sfx, S.settings.music); L.reducedMotion = !!S.settings.reducedMotion || matchMedia('(prefers-reduced-motion: reduce)').matches; save(); }; -G.grant = (pid, how) => { S.owned[pid] = how; save(); emit('owned', pid); }; +G.grant = (pid, how) => { if (globalThis.LilopiaAndroid) return false; S.owned[pid] = how; save(); emit('owned', pid); }; G.revoke = pid => { delete S.owned[pid]; save(); emit('owned', pid); }; G.findChar = id => S.chars.find(c => c.id === id); diff --git a/src/logic.js b/src/logic.js index 442e7d2..3ac9d9c 100644 --- a/src/logic.js +++ b/src/logic.js @@ -20,7 +20,13 @@ function lookKey(field, value) { return prefix + ':' + (field === 'hairColor' && value[0] === '#' ? value.toLowerCase() : value).replace('#7fe0c4', 'mint'); } const contentProduct = id => contentIndex[id] || null; -const isOwned = (state, pid) => !!(state && state.owned && state.owned[pid]); +const isOwned = (state, pid) => { + if (globalThis.LilopiaAndroid) { + try { return !!PRODUCTS[pid] && globalThis.LilopiaAndroid.owns(PRODUCTS[pid].productId); } + catch (_) { return false; } + } + return !!(state && state.owned && state.owned[pid]); +}; const isLocked = (state, contentId) => { const p = contentProduct(contentId); return !!p && !isOwned(state, p); }; const lookLocked = (state, field, value) => isLocked(state, lookKey(field, value)); diff --git a/src/ui.js b/src/ui.js index d9a4814..4347edb 100644 --- a/src/ui.js +++ b/src/ui.js @@ -9,6 +9,17 @@ const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const S = () => G.state(); const sfx = n => L.sfx(n); +const play = globalThis.LilopiaAndroid; +const owned = pid => play ? (logic.isOwned(S(), pid) ? 'verified' : null) : S().owned[pid]; +const price = p => play ? (play.price(p.productId) || 'Store unavailable') : p.price; +globalThis.lilopiaStoreChanged = message => { + if (message) toast(message); + if (S()) { + const location = L.LOCATIONS[S().scene.split('/')[0]]; + if (location && location.product && !logic.isOwned(S(), location.product)) G.goto('home/living'); + renderDock(); if (stack.length) drawSheet(); + } +}; /* ---------------- icons ---------------- */ const P = { @@ -524,13 +535,13 @@ function openPreview(pid) { body.appendChild(el('p', 'fine', esc(p.blurb))); const inc = el('ul', 'inc'); for (const i of p.includes) inc.appendChild(el('li', null, esc(i))); body.appendChild(group("What's included", inc)); const row = el('div'); row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '12px'; row.style.flexWrap = 'wrap'; - row.appendChild(el('span', 'price', esc(p.price))); - const how = S().owned[pid]; + row.appendChild(el('span', 'price', esc(price(p)))); + const how = owned(pid); if (how) row.appendChild(el('span', 'pill ' + (how === 'dev' ? 'dev' : 'owned'), how === 'dev' ? 'Unlocked by a development flag' : 'Unlocked')); else if (!p.available) row.appendChild(el('span', 'pill soon', 'Coming soon')); body.appendChild(row); if (!how && !p.available) body.appendChild(el('p', 'fine', 'This pack is still being built, so it cannot be bought yet. It stays on the map so you can see what is coming.')); - if (!how && p.available) body.appendChild(el('p', 'fine', 'Only a grown-up can unlock packs. Purchases in this prototype are simulated and no money is charged.')); + if (!how && p.available) body.appendChild(el('p', 'fine', play ? 'Only a grown-up can buy packs through Google Play. Purchases must be verified before packs unlock.' : 'Only a grown-up can unlock packs. Purchases in this prototype are simulated and no money is charged.')); box.appendChild(body); const foot = el('footer'); foot.appendChild(el('span', 'sp')); const close = el('button', 'btn', 'Close'); close.addEventListener('click', closeSheet); foot.appendChild(close); @@ -565,28 +576,29 @@ function openPurchase(pid) { pushSheet(box => { box.classList.add('narrow'); sheetHead(box, 'Confirm purchase', 'Parent purchase screen'); const body = el('div', 'body'); - body.appendChild(el('div', 'gateq', `${esc(p.name)}: ${esc(p.price)}`)); - body.appendChild(el('p', 'fine', `Store product ${esc(p.productId)}. This prototype simulates the store, so no money is charged. A released game would show the Google Play or App Store payment sheet here, then verify the purchase.`)); + body.appendChild(el('div', 'gateq', `${esc(p.name)}: ${esc(price(p))}`)); + body.appendChild(el('p', 'fine', play ? 'Google Play confirms the final price. Packs unlock only after payment verification. If payment is pending or verification is unavailable, try Restore purchases later.' : `Store product ${esc(p.productId)}. This prototype simulates the store, so no money is charged.`)); box.appendChild(body); const foot = el('footer'); foot.appendChild(el('span', 'sp')); const cancel = el('button', 'btn', 'Cancel'); cancel.addEventListener('click', closeSheet); - const buy = el('button', 'btn pink', svg('check') + 'Buy (simulated)'); - buy.addEventListener('click', () => { G.grant(pid, 'simulated'); sfx('ding'); toast(`${p.name} unlocked`); closeSheet(); if (stack.length) drawSheet(); renderDock(); }); + const buy = el('button', 'btn pink', svg('check') + (play ? 'Buy with Google Play' : 'Buy (simulated)')); + buy.disabled = !!play && !play.available(p.productId); + buy.addEventListener('click', () => { if (play) { play.buy(p.productId); return; } G.grant(pid, 'simulated'); sfx('ding'); toast(`${p.name} unlocked`); closeSheet(); if (stack.length) drawSheet(); renderDock(); }); foot.append(cancel, buy); box.appendChild(foot); }); } function openStore() { pushSheet(box => { - sheetHead(box, 'Parents: store and purchases', 'Every expansion is $5.99. Purchases are simulated in this prototype.'); + sheetHead(box, 'Parents: store and purchases', play ? 'Optional expansion packs. Google Play supplies local prices.' : 'Every expansion is $5.99. Purchases are simulated in this prototype.'); const body = el('div', 'body'); for (const [pid, p] of Object.entries(L.PRODUCTS)) { - const row = el('div', 'prow'), how = S().owned[pid]; + const row = el('div', 'prow'), how = owned(pid); row.appendChild(p.location && L.LOCATIONS[p.location] ? mapIconThumb(L.LOCATIONS[p.location].icon, 72, 60) : contentThumb(p.content[0], 72, 60)); - const t = el('div'); t.appendChild(el('h3', null, esc(p.name))); t.appendChild(el('p', null, `${KIND[p.kind]} · ${esc(p.price)} · ${esc(p.blurb)}`)); row.appendChild(t); + const t = el('div'); t.appendChild(el('h3', null, esc(p.name))); t.appendChild(el('p', null, `${KIND[p.kind]} · ${esc(price(p))} · ${esc(p.blurb)}`)); row.appendChild(t); const acts = el('div', 'acts'); if (how) acts.appendChild(el('span', 'pill ' + (how === 'dev' ? 'dev' : 'owned'), how === 'dev' ? 'Dev flag' : 'Owned')); else if (!p.available) acts.appendChild(el('span', 'pill soon', 'Coming soon')); - else { const b = el('button', 'btn primary', 'Buy (simulated)'); b.addEventListener('click', () => { G.grant(pid, 'simulated'); sfx('ding'); toast(`${p.name} unlocked`); drawSheet(); renderDock(); }); acts.appendChild(b); } + else { const b = el('button', 'btn primary', play ? 'Buy with Google Play' : 'Buy (simulated)'); b.disabled = !!play && !play.available(p.productId); b.addEventListener('click', () => { if (play) { openPurchase(pid); return; } G.grant(pid, 'simulated'); sfx('ding'); toast(`${p.name} unlocked`); drawSheet(); renderDock(); }); acts.appendChild(b); } const info = el('button', 'btn', 'Details'); info.addEventListener('click', () => openPreview(pid)); acts.appendChild(info); row.appendChild(acts); body.appendChild(row); } @@ -594,7 +606,7 @@ function openStore() { box.appendChild(body); const foot = el('footer'); const restore = el('button', 'btn', 'Restore purchases'); - restore.addEventListener('click', () => { const n = Object.values(S().owned).filter(v => v === 'simulated').length; G.saveNow(); toast(`Restored ${n} purchase${n === 1 ? '' : 's'} saved on this device`); }); + restore.addEventListener('click', () => { if (play) { play.restore(); return; } const n = Object.values(S().owned).filter(v => v === 'simulated').length; G.saveNow(); toast(`Restored ${n} simulated purchase${n === 1 ? '' : 's'} saved on this device`); }); foot.append(restore, el('span', 'sp')); const done = el('button', 'btn primary', 'Done'); done.addEventListener('click', closeSheet); foot.appendChild(done); box.appendChild(foot); @@ -622,13 +634,15 @@ function openSettings(fromTitle) { if (!fromTitle) { body.appendChild(el('p', 'sec', 'Parents')); const store = el('button', 'btn', svg('lock') + 'Store and purchases'); store.addEventListener('click', () => openGate(openStore)); body.appendChild(store); + if (!play) { body.appendChild(el('p', 'sec', 'Development only')); body.appendChild(el('p', 'fine', 'Ownership flags for testing locked and unlocked content without the store.')); const dev = el('div', 'set'); for (const [pid, p] of Object.entries(L.PRODUCTS)) { if (!p.available) continue; dev.append(el('label', null, esc(p.name)), toggle(!!S().owned[pid], v => { if (v) G.grant(pid, 'dev'); else G.revoke(pid); renderDock(); }, `${p.name} development flag`)); } body.appendChild(dev); + } } - body.appendChild(el('p', 'fine', 'LILOPIA has no chat, no ads, no accounts and no location sharing, and collects no personal information. Your world saves automatically on this device.')); + body.appendChild(el('p', 'fine', play ? 'LILOPIA has no chat, ads, accounts or location sharing. Your world saves on this device. Purchases send the product ID and Google Play purchase token to our verification service and Google Play. Packs require an online check after opening the app. Clear Android app storage to delete your saved world.' : 'LILOPIA has no chat, no ads, no accounts and no location sharing, and collects no personal information. Your world saves automatically on this device.')); box.appendChild(body); const foot = el('footer'); if (!fromTitle) { diff --git a/tests/release.test.js b/tests/release.test.js new file mode 100644 index 0000000..7bcf11d --- /dev/null +++ b/tests/release.test.js @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +function load(android) { + const c = vm.createContext({ console, LilopiaAndroid: android }); + for (const f of ['core', 'data', 'logic']) vm.runInContext(fs.readFileSync(`src/${f}.js`, 'utf8'), c); + return c.L; +} +test('Android ignores simulated, development and forged saved entitlements', () => { + const L = load({ owns: () => false }); + for (const how of ['simulated', 'dev', 'verified', true]) { + assert.equal(L.logic.isOwned({ owned: { exp_beach_vacation: how } }, 'exp_beach_vacation'), false); + } +}); +test('Android entitlement comes from native verification, independent of save flags', () => { + const L = load({ owns: id => id === 'com.lilopia.expansion.beach_vacation' }); + assert.equal(L.logic.isOwned({ owned: {} }, 'exp_beach_vacation'), true); + assert.equal(L.logic.isOwned({ owned: {} }, 'exp_city_life'), false); +}); +test('browser prototype keeps its explicitly simulated entitlement behavior', () => { + const L = load(undefined); + assert.equal(L.logic.isOwned({ owned: { exp_beach_vacation: 'simulated' } }, 'exp_beach_vacation'), true); +}); diff --git a/tests/verification.test.js b/tests/verification.test.js new file mode 100644 index 0000000..4027d53 --- /dev/null +++ b/tests/verification.test.js @@ -0,0 +1,45 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { verifyPurchase } = require('../server/verify.cjs'); +const productId = 'com.lilopia.expansion.beach_vacation'; +const request = { productId, purchaseToken: 'test-token-not-a-real-purchase' }; +function provider(purchase) { + const calls = []; + return { calls, get: async (...args) => { calls.push(['get', ...args]); return purchase; }, + acknowledge: async (...args) => { calls.push(['ack', ...args]); } }; +} +test('rejects pending, canceled, consumed and multi-quantity purchases without acknowledging', async () => { + for (const data of [{ purchaseState: 2 }, { purchaseState: 1 }, { purchaseState: 0, consumptionState: 1 }, { purchaseState: 0, quantity: 2 }]) { + const api = provider(data); + await assert.rejects(verifyPurchase(request, api, async () => {})); + assert.equal(api.calls.some(c => c[0] === 'ack'), false); + } +}); +test('rejects unavailable and ad-removal products before calling Google', async () => { + const api = provider({ purchaseState: 0 }); + for (const id of ['com.lilopia.remove_ads', 'com.lilopia.expansion.pet_paradise', '../other']) + await assert.rejects(verifyPurchase({ ...request, productId: id }, api, async () => {})); + assert.equal(api.calls.length, 0); +}); +test('persists verified token hash then acknowledges before returning entitlement', async () => { + const events = [], api = provider({ purchaseState: 0, acknowledgementState: 0, consumptionState: 0, quantity: 1 }); + const result = await verifyPurchase(request, api, async (hash, id) => { assert.equal(hash.length, 64); assert.equal(id, productId); events.push('persisted'); }); + assert.equal(result.verified, true); + assert.equal(result.productId, productId); + assert.equal(events.length, 1); + assert.deepEqual(api.calls.map(c => c[0]), ['get', 'ack']); +}); +test('restore rechecks Google and does not acknowledge an acknowledged purchase twice', async () => { + const api = provider({ purchaseState: 0, acknowledgementState: 1 }); + await verifyPurchase(request, api, async () => {}); + await verifyPurchase(request, api, async () => {}); + assert.deepEqual(api.calls.map(c => c[0]), ['get', 'get']); +}); +test('Google errors, persistence errors and acknowledgment errors fail closed', async () => { + await assert.rejects(verifyPurchase(request, { get: async () => { throw Error('offline'); } }, async () => {})); + const api = provider({ purchaseState: 0, acknowledgementState: 0 }); + await assert.rejects(verifyPurchase(request, api, async () => { throw Error('disk full'); })); + assert.equal(api.calls.some(c => c[0] === 'ack'), false); + api.acknowledge = async () => { throw Error('ack failed'); }; + await assert.rejects(verifyPurchase(request, api, async () => {})); +}); From 768dabc5f2cf6755e7c4e867de9b1b11547e8543 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 05:34:14 -0500 Subject: [PATCH 02/11] chore: document release gates and add repeatable Android checks --- .github/workflows/android.yml | 27 ++++ PLAY-RELEASE.md | 120 ++++++++++++++++++ PRIVACY-POLICY-DRAFT.md | 17 +++ .../java/com/lilopia/game/MainActivity.java | 8 ++ .../main/java/com/lilopia/game/PlayStore.java | 9 +- tests/e2e/android-release.js | 30 +++++ tools/Sign-PlayBundle.ps1 | 23 ++++ 7 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 PLAY-RELEASE.md create mode 100644 PRIVACY-POLICY-DRAFT.md create mode 100644 tests/e2e/android-release.js create mode 100644 tools/Sign-PlayBundle.ps1 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..b0c55e9 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,27 @@ +name: Android release candidate +on: + workflow_dispatch: +permissions: + contents: read +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: android-actions/setup-android@v3 + - run: sdkmanager 'platforms;android-36' 'build-tools;35.0.0' + - run: node --test tests/*.test.js + - run: bash android/gradlew -p android bundleRelease lintRelease testReleaseUnitTest --console=plain + - uses: actions/upload-artifact@v4 + with: + name: lilopia-unsigned-release-candidate + path: | + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/lint-results-release.html diff --git a/PLAY-RELEASE.md b/PLAY-RELEASE.md new file mode 100644 index 0000000..7f9b05d --- /dev/null +++ b/PLAY-RELEASE.md @@ -0,0 +1,120 @@ +# LILOPIA Google Play release handoff + +Prepared October 21, 2026. Release candidate, not approved for production. + +## Application and artifact + +- Existing game and bundled art retained; no game replacement or remote wrapper dependency. +- Application ID / namespace: `com.lilopia.game` (retained from repository). Check availability when creating the first Play app; do not change after first upload. +- Version name `1.0.0`, version code `2`; increment code for every later upload (`-PversionCode=3`). +- Minimum Android API 24; compile/target API 36. Android Gradle Plugin 8.10.1, Gradle 8.14.3, JDK 17. +- `bundleRelease` produces an **unsigned release AAB** when no upload key is configured. There is no debug signing fallback. Sign with your upload key before Play accepts it. +- Existing multi-density/adaptive launcher icon and Android 12+ splash retained. Store icon: `icons/icon-512.png`. +- No native `.so` libraries are intentionally included; check the final bundle for accidental native dependencies and run Play pre-launch tests, including 16 KB page-size devices. +- WebView allows only bundled HTTPS asset-origin resources, disables file/content/geolocation/mixed-content access, and blocks outside navigation. Release WebView debugging is disabled. +- Manifest requests INTERNET for native purchase verification; Billing Library merges its billing permission. No camera, microphone, location, advertising ID, contacts, storage or notification permissions are requested by application code. Inspect the merged manifest to confirm dependencies. +- Android backup disabled. Saved world stays on the device; clearing app storage/uninstalling deletes it. Progress is not restored from the website. + +## Build and upload signing + +From the repository root, with JDK 17 and Android SDK installed: + +```powershell +node --test tests/*.test.js +./android/gradlew.bat -p android bundleRelease lintRelease testReleaseUnitTest --console=plain +``` + +Output: `android/app/build/outputs/bundle/release/app-release.aab`. + +Use `tools/Sign-PlayBundle.ps1` to sign an existing unsigned AAB with an existing upload keystore. Passwords are prompted securely and passed to jarsigner using temporary process environment variables, never command-line values. The script clears them in `finally`. Do not put a keystore or passwords in source control, a public release, the source ZIP, screenshots, chat, or an AAB. Keep encrypted backups of your upload key in your own secure storage. + +If this is your first app and you have no upload key, generate one with keytool's interactive prompts (the command contains no password): + +```powershell +keytool -genkeypair -v -keystore C:\YourSecureFolder\lilopia-upload.jks -alias lilopia-upload -keyalg RSA -keysize 3072 -validity 10000 +``` + +Use truthful certificate details. Google holds the app signing key through Play App Signing; your upload key signs uploads. An upload key is not Google's distribution signing key. Do not upload an unsigned AAB directly. + +## Billing status — not live tested + +The original game used simulated purchases and local-only restore. Android now ignores those flags and development flags, hides development controls, disables simulated grants, sanitizes a saved premium scene when access is absent, and uses native verified ownership for all content locks. The browser prototype retains its clearly labeled simulations. + +Native Google Play Billing Library **8.0.0** is integrated. The Android flow queries product details/local prices, has a native grown-up gate, launches Play's payment sheet, handles cancellation/pending/already-owned responses, queries purchases on resume/restore, and sends a token to the configured HTTPS verifier. It grants access only after a positive verification response. No consumptions are performed for these durable purchases. + +**The supplied candidate has an empty verification URL and purchases are deliberately disabled.** It is useful for packaging/gameplay tests and setting up the app, not for monetized production. This avoids taking a payment that cannot be verified or fulfilled. It must be rebuilt with the deployed verifier before billing tests: + +```powershell +./android/gradlew.bat -p android bundleRelease -PversionCode=3 -PverificationUrl=https://YOUR-VERIFIER-HOST/verify +``` + +The URL is configuration, not a secret. It must be an actual HTTPS endpoint. No placeholder URL was embedded. + +### Play products to configure + +| Play product ID | Type | US base price | Status | +|---|---|---|---| +| `com.lilopia.expansion.dream_mansion` | One-time, non-consumable | USD 5.99 | Playable content; configure then test | +| `com.lilopia.expansion.beach_vacation` | One-time, non-consumable | USD 5.99 | Playable content; configure then test | +| `com.lilopia.expansion.city_life` | One-time, non-consumable | USD 5.99 | Playable content; configure then test | +| Ad removal (no active product) | Would be non-consumable | USD 3.99 requested | **Do not activate or sell: this build has no ads** | + +Seven additional expansion previews remain coming soon and cannot be bought. Do not activate products for undelivered content. Cosmetic/seasonal prototype products are not enabled in the native store. Play Console prices, tax, availability and regional conversions must be configured there; hardcoded prototype dollar strings are not proof of a Play price. + +### Verification service + +`server/` contains a Node 24 service and locked dependencies. Deploy behind HTTPS with persistent disk for SQLite, request/time/connection limits, and managed application credentials. Run `npm ci` in `server/`, set `DATABASE_PATH` to the persistent disk location, and start with `npm start`. Do not deploy a public unauthenticated shell or expose the database. + +Enable Google Play Android Developer API in the service's Cloud project. Give its service account the minimum app-scoped Play permissions required to view and manage these orders. Use workload identity/managed service credentials where possible; if a key file is needed, mount it as a protected server secret via `GOOGLE_APPLICATION_CREDENTIALS`. Never bundle it into Android, commit it, or paste it into chat. + +`POST /verify` accepts only `{productId,purchaseToken}`. Package name is fixed on the server. It checks Google's package/product/token endpoint, rejects pending/canceled/consumed/multiple-quantity orders, records a SHA-256 token hash with a unique key, acknowledges verified non-consumable purchases through Google, and returns verified access only after acknowledgment succeeds. Every restore rechecks Google. Tokens are not logged or stored in plaintext by this code. Service errors fail closed. No app account is needed; the token is a sensitive bearer proof and transport must remain HTTPS. + +This candidate requires an online ownership check after each app launch; it does not promise offline access to paid packs. Current-session ownership is removed on successful restore if Google no longer reports an active purchase. Refunds/revocations are enforced at the next successful ownership check, not in real time. There is no background RTDN worker. Monitor failed acknowledgments and ensure retries complete within Google's acknowledgment deadline; otherwise Google can refund a purchase. For continuous off-device processing, add authenticated RTDN and durable retry jobs before claiming that behavior. + +## Families and privacy + +- Intended audience is ages 3–12. Select the Play age groups **5 and under**, **6–8**, and **9–12**, based on the actual product. Do not select adult audiences to avoid Families requirements. +- No ads/advertising SDKs, targeted ads, public chat, user accounts, social links, analytics SDKs, location sharing, purchased coins, randomized paid rewards, subscriptions or recurring billing. +- Optional purchase prompts remain behind the grown-up flow; free gameplay remains available. A multiplication gate does not establish parental legal consent. Do not claim COPPA or Families approval from local tests. +- Do not charge for ad removal while no advertising exists. If ads are later added, treat that as a separate policy/privacy review and use only currently compliant Families-certified ad SDK versions and placements. +- Review `PRIVACY-POLICY-DRAFT.md`, replace the missing public contact and actual service/retention details, publish it at a public HTTPS URL, and expose the same policy in the app before review. Do not publish the placeholder draft as a final policy. +- Data Safety must reflect the final deployed verifier and hosting logs: purchase history/product IDs and tokens are processed for purchase verification; hosting may process IP/network metadata. Assess the Play definitions and processor/service-provider exceptions before choosing collected/shared fields. Do not claim 'no data collected' merely because gameplay is offline. +- No account deletion URL is needed for an app with no accounts, but provide a contact route for privacy requests and a clear local-data deletion explanation. Review this again if accounts or analytics are added. + +## Account-specific remaining steps + +Read-only inspection on October 21, 2026 found the signed-in Play Console showing a personal developer account and **Create your first app**. The account contact page showed email verified and a **Verify phone number** action. No app, live products, previous upload key, or billing test transactions were available to verify. Complete the required account verification shown by Console; do not treat the presence of a dashboard as publishing eligibility. + +1. Finish developer account setup, phone/identity/device verification if requested. Human identity/OTP and legal agreements remain the account owner's steps. +2. Create **LILOPIA**, default language English (US), **Game**, **Free**. Read and accept the required declarations as the account owner. Check package `com.lilopia.game` on the first signed AAB upload; it becomes permanent for the app. +3. Complete store listing, support contact and public privacy policy. Use the supplied 512px icon. Add accurate screenshots from the final Android game and a 1024×500 feature graphic; browser screenshots are development evidence, not device verification. Do not advertise unfinished expansions as playable. +4. Complete App content: target audience and Families, Ads = No, content rating questionnaire (answer truthfully), app access (no login; explain parent gate), Data Safety, and any country-specific requirements. Do not invent a rating or claim Teacher Approved status. +5. Select Google-managed Play App Signing. Sign the AAB with your protected upload key. Start with **Test and release > Testing > Internal testing > Create release**. Upload the signed candidate and inspect bundle/package/version/permissions warnings. This candidate has billing disabled and is not the final monetized build. +6. Configure a merchant/payments profile if required to sell in-app products. Create/activate the three one-time products above at USD 5.99, regional prices/availability, with no multi-quantity or consumable behavior. Do not activate $3.99 ad removal. +7. Deploy the verifier with app-scoped Developer API access and persistent storage; finish the final privacy disclosure, set its real URL, increment the version code, rebuild, re-run checks and sign the new AAB. +8. Under Console account settings, add **License testing** accounts; add the same accounts to the internal test, and have each opt in and install the Play-distributed build. Use Play Billing Lab/test payment methods; a regular tester can otherwise incur real charges. +9. Record evidence for all billing tests below. Only after these pass should purchases be described as working. +10. Run Play pre-launch report and physical phone/tablet tests: API 24 and 36, WebView compatibility, first launch, splash/icon, touch/drag, rotation/resizing, system back, audio interruption, background/resume, saved-world recovery, offline free play, parent gate, price display, and accessibility. No connected Android device was available during local inspection. +11. If Console applies the new personal-account requirement, run a closed test with at least 12 opted-in testers continuously for 14 days, then apply for production access and answer the production-readiness questions. Internal testing does not satisfy this requirement. +12. Resolve all review/policy findings, then submit a tested production release. This task did not submit or publish one. + +### Billing acceptance evidence still required + +- Each pack shows its real localized Play price and the Play payment sheet. +- Approved test purchase unlocks the correct delivered pack, with Google server verification and acknowledgment recorded. +- Cancel/decline/pending payment grants nothing. A completed pending purchase later unlocks after re-query. +- Network/server outage takes no new payment when the store is unavailable; a completed payment that cannot verify stays locked and restores after service recovery without double charge. +- Duplicate callbacks and restores are idempotent; already-owned purchases restore rather than sell again. +- Reinstall/clear storage and same Play account restore all owned packs; a different account does not inherit them. +- Refund/revoke, then resume/restore: access disappears. Confirm the documented enforcement timing. +- No development controls, simulated purchase grants, forged-save unlocks, accidental ads, web checkout, or purchased coins appear in the Play build. + +## Official references checked for this work + +- Target API: https://developer.android.com/google/play/requirements/target-sdk +- Families: https://support.google.com/googleplay/android-developer/answer/9893335 +- Billing integration: https://developer.android.com/google/play/billing/integrate +- Purchase verification: https://developer.android.com/google/play/billing/security +- Billing testing: https://developer.android.com/google/play/billing/test +- Signing: https://support.google.com/googleplay/android-developer/answer/9842756 +- Personal-account testing: https://support.google.com/googleplay/android-developer/answer/14151465 diff --git a/PRIVACY-POLICY-DRAFT.md b/PRIVACY-POLICY-DRAFT.md new file mode 100644 index 0000000..9cbf229 --- /dev/null +++ b/PRIVACY-POLICY-DRAFT.md @@ -0,0 +1,17 @@ +# LILOPIA privacy policy — draft for owner review + +**Not ready to publish: public contact, hosting configuration, retention schedule and release date must be completed.** + +Effective date: [FINAL RELEASE DATE] + +LILOPIA is a pretend-play game for children ages 3–12, published by [CONFIRMED PUBLIC DEVELOPER NAME]. Questions and privacy requests: [PUBLIC SUPPORT/PRIVACY EMAIL]. + +The game does not require an account, contain public chat, show advertisements, use advertising identifiers, track location, or include analytics or social media SDKs. Characters, household names, preferences and world progress are stored locally on the device. We do not transmit that saved world to a server. You can delete it through Android Settings > Apps > LILOPIA > Storage > Clear storage. Uninstalling also removes local progress. Purchases may be restored separately through Google Play. + +Optional expansion packs are offered through a grown-up flow and Google's Play payment sheet. When a purchase or restore is checked, the app sends the product ID and Google Play purchase token over encrypted HTTPS to our purchase verification service. That service checks the purchase with Google Play and acknowledges valid payments. We do not receive full payment-card details from Google Play. Google processes payments under its own terms and privacy policy. + +Our verification service stores a one-way hash of the purchase token, the associated product ID and the last verification time to prevent inconsistent transaction processing. [CONFIRM RETENTION PERIOD AND DELETION PROCESS]. The hosting provider may process IP addresses and connection metadata to deliver and protect the service. [NAME HOSTING PROVIDER, LOGGING SETTINGS, RETENTION AND ANY ADDITIONAL PROCESSORS]. We do not use purchase data for advertising or profiling children and do not sell it. + +Parents can contact [PUBLIC SUPPORT/PRIVACY EMAIL] for questions about purchase verification records, access or deletion. Please do not send a child's full name, birth date, payment-card information or purchase tokens by email. [DESCRIBE SECURE REQUEST VERIFICATION AND THE RIGHTS/APPEAL PROCESS REQUIRED IN ACTUAL DISTRIBUTION REGIONS]. + +This draft describes the supplied implementation. The publisher must confirm the final deployed behavior, service providers, logging, retention, geographic requirements and contact details before publication. A grown-up gate is a purchase safety feature; it is not proof of parental legal consent. diff --git a/android/app/src/main/java/com/lilopia/game/MainActivity.java b/android/app/src/main/java/com/lilopia/game/MainActivity.java index e616bf3..079465e 100644 --- a/android/app/src/main/java/com/lilopia/game/MainActivity.java +++ b/android/app/src/main/java/com/lilopia/game/MainActivity.java @@ -82,6 +82,10 @@ public boolean shouldOverrideUrlLoading(@NonNull WebView view, @NonNull WebResou if (savedInstanceState != null) web.restoreState(savedInstanceState); if (web.getUrl() == null) web.loadUrl(START_URL); + if (Build.VERSION.SDK_INT >= 33) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::handleGameBack); + } hideSystemBars(); } @@ -89,6 +93,10 @@ public boolean shouldOverrideUrlLoading(@NonNull WebView view, @NonNull WebResou @Override @SuppressWarnings("deprecation") public void onBackPressed() { + handleGameBack(); + } + + private void handleGameBack() { web.evaluateJavascript("(window.lilopiaBack && window.lilopiaBack()) ? 1 : 0", result -> { if (!"1".equals(result)) moveTaskToBack(true); }); diff --git a/android/app/src/main/java/com/lilopia/game/PlayStore.java b/android/app/src/main/java/com/lilopia/game/PlayStore.java index 2a54a3d..8ac00fd 100644 --- a/android/app/src/main/java/com/lilopia/game/PlayStore.java +++ b/android/app/src/main/java/com/lilopia/game/PlayStore.java @@ -47,7 +47,8 @@ public void onBillingSetupFinished(BillingResult result) { ? d.getOneTimePurchaseOfferDetails().getFormattedPrice() : ""; } @JavascriptInterface public boolean available(String id) { - return !closed && client.isReady() && details.containsKey(id) && !BuildConfig.VERIFICATION_URL.isEmpty(); + ProductDetails d = details.get(id); + return !closed && client.isReady() && d != null && d.getOneTimePurchaseOfferDetails() != null && !BuildConfig.VERIFICATION_URL.isEmpty(); } @JavascriptInterface public void buy(String id) { if (!PRODUCTS.contains(id) || !available(id)) { notifyWeb("Purchases are unavailable. No payment was started."); return; } @@ -60,8 +61,10 @@ public void onBillingSetupFinished(BillingResult result) { if (!String.valueOf(a * b).equals(answer.getText().toString().trim())) return; ProductDetails d = details.get(id); if (d == null || !client.isReady()) { notifyWeb("Store unavailable. Please try again."); return; } - BillingFlowParams.ProductDetailsParams product = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(d).build(); + BillingFlowParams.ProductDetailsParams.Builder selected = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(d); + ProductDetails.OneTimePurchaseOfferDetails offer = d.getOneTimePurchaseOfferDetails(); + if (offer != null && offer.getOfferToken() != null) selected.setOfferToken(offer.getOfferToken()); + BillingFlowParams.ProductDetailsParams product = selected.build(); BillingResult result = client.launchBillingFlow(activity, BillingFlowParams.newBuilder() .setProductDetailsParamsList(Collections.singletonList(product)).build()); if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) notifyWeb("Payment could not start. Please try again."); diff --git a/tests/e2e/android-release.js b/tests/e2e/android-release.js new file mode 100644 index 0000000..01e99b2 --- /dev/null +++ b/tests/e2e/android-release.js @@ -0,0 +1,30 @@ +// Browser integration test of the Android UI contract. This does not test Play checkout. +const { chromium } = require('playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +(async () => { + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ viewport: { width: 1000, height: 600 } }); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.addInitScript(() => { + window.LilopiaAndroid = { owns: () => false, price: () => '', available: () => false, restore: () => {}, buy: () => { throw Error('Payment must stay disabled'); } }; + }); + await page.goto(pathToFileURL(path.resolve('index.html')).href); + await page.click('#newBtn'); + await page.locator('#sheetBox .btn.primary').click(); + await page.waitForSelector('#app:not([hidden])'); + assert.equal(await page.evaluate(() => L.game.grant('exp_beach_vacation', 'simulated')), false); + assert.equal(await page.evaluate(() => L.game.goto('beach/beach')), false); + await page.keyboard.press('Escape'); + await page.evaluate(() => { + const s = L.game.snapshot(); s.owned.exp_beach_vacation = 'verified'; s.scene = 'beach/beach'; L.game.start(s); + }); + assert.equal(await page.evaluate(() => L.game.sceneId()), 'home/living'); + assert.equal(await page.evaluate(() => L.logic.isOwned(L.game.state(), 'exp_beach_vacation')), false); + assert.deepEqual(errors, []); + console.log('PASS Android rejects simulated grant, blocks premium travel, and sanitizes forged saved scene; no browser errors'); + await page.screenshot({ path: '../android-ui-contract.png' }); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tools/Sign-PlayBundle.ps1 b/tools/Sign-PlayBundle.ps1 new file mode 100644 index 0000000..c2cd441 --- /dev/null +++ b/tools/Sign-PlayBundle.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory=$true)][string]$Bundle, + [Parameter(Mandatory=$true)][string]$Keystore, + [string]$Alias = 'lilopia-upload', + [Parameter(Mandatory=$true)][string]$Output +) +$ErrorActionPreference = 'Stop' +$taskSigner = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME 'bin/jarsigner.exe' } else { 'jarsigner.exe' } +$taskStorePassword = Read-Host 'Upload keystore password (never printed)' -AsSecureString +$taskKeyPassword = Read-Host 'Upload key password (never printed)' -AsSecureString +try { + $env:LILOPIA_STORE_PASSWORD = [System.Net.NetworkCredential]::new('', $taskStorePassword).Password + $env:LILOPIA_KEY_PASSWORD = [System.Net.NetworkCredential]::new('', $taskKeyPassword).Password + & $taskSigner -keystore (Resolve-Path -LiteralPath $Keystore).Path -storepass:env LILOPIA_STORE_PASSWORD -keypass:env LILOPIA_KEY_PASSWORD -signedjar $Output (Resolve-Path -LiteralPath $Bundle).Path $Alias + if ($LASTEXITCODE -ne 0) { throw 'Bundle signing failed' } + & $taskSigner -verify $Output + if ($LASTEXITCODE -ne 0) { throw 'Signature verification failed' } + Write-Output "Signed bundle: $Output" +} finally { + Remove-Item Env:LILOPIA_STORE_PASSWORD -ErrorAction SilentlyContinue + Remove-Item Env:LILOPIA_KEY_PASSWORD -ErrorAction SilentlyContinue + $taskStorePassword.Dispose(); $taskKeyPassword.Dispose() +} From 0f984e36db32fa61513841235ebc07916c40d127 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 05:41:12 -0500 Subject: [PATCH 03/11] fix: validate release versioning and finalize Android packaging evidence --- PLAY-RELEASE.md | 2 +- PRIVACY-POLICY-DRAFT.md | 2 +- VALIDATION.md | 28 +++++++++++++++++++ android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 4 ++- .../main/java/com/lilopia/game/PlayStore.java | 15 ++++++++-- .../main/res/xml/data_extraction_rules.xml | 25 +++++++++++++++++ .../gradle/wrapper/gradle-wrapper.properties | 1 + tests/e2e/android-release.js | 14 +++++++++- 9 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 VALIDATION.md create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml diff --git a/PLAY-RELEASE.md b/PLAY-RELEASE.md index 7f9b05d..faa4817 100644 --- a/PLAY-RELEASE.md +++ b/PLAY-RELEASE.md @@ -12,7 +12,7 @@ Prepared October 21, 2026. Release candidate, not approved for production. - Existing multi-density/adaptive launcher icon and Android 12+ splash retained. Store icon: `icons/icon-512.png`. - No native `.so` libraries are intentionally included; check the final bundle for accidental native dependencies and run Play pre-launch tests, including 16 KB page-size devices. - WebView allows only bundled HTTPS asset-origin resources, disables file/content/geolocation/mixed-content access, and blocks outside navigation. Release WebView debugging is disabled. -- Manifest requests INTERNET for native purchase verification; Billing Library merges its billing permission. No camera, microphone, location, advertising ID, contacts, storage or notification permissions are requested by application code. Inspect the merged manifest to confirm dependencies. +- Final merged permissions verified: `INTERNET`, `ACCESS_NETWORK_STATE`, and `com.android.vending.BILLING`. No camera, microphone, location, advertising ID, contacts, storage or notification permissions are present. Google Play Billing includes Google transport/diagnostic dependencies; review their current SDK disclosures as well as app/server data handling before final Data Safety answers. - Android backup disabled. Saved world stays on the device; clearing app storage/uninstalling deletes it. Progress is not restored from the website. ## Build and upload signing diff --git a/PRIVACY-POLICY-DRAFT.md b/PRIVACY-POLICY-DRAFT.md index 9cbf229..fd3113c 100644 --- a/PRIVACY-POLICY-DRAFT.md +++ b/PRIVACY-POLICY-DRAFT.md @@ -8,7 +8,7 @@ LILOPIA is a pretend-play game for children ages 3–12, published by [CONFIRMED The game does not require an account, contain public chat, show advertisements, use advertising identifiers, track location, or include analytics or social media SDKs. Characters, household names, preferences and world progress are stored locally on the device. We do not transmit that saved world to a server. You can delete it through Android Settings > Apps > LILOPIA > Storage > Clear storage. Uninstalling also removes local progress. Purchases may be restored separately through Google Play. -Optional expansion packs are offered through a grown-up flow and Google's Play payment sheet. When a purchase or restore is checked, the app sends the product ID and Google Play purchase token over encrypted HTTPS to our purchase verification service. That service checks the purchase with Google Play and acknowledges valid payments. We do not receive full payment-card details from Google Play. Google processes payments under its own terms and privacy policy. +When purchases are enabled, optional expansion packs are offered through a grown-up flow and Google's Play payment sheet. When a purchase or restore is checked, the app sends the product ID and Google Play purchase token over encrypted HTTPS to our purchase verification service. That service checks the purchase with Google Play and acknowledges valid payments. We do not receive full payment-card details from Google Play. Google processes payments under its own terms and privacy policy. The Google Play Billing SDK also includes Google transport/diagnostic dependencies; [CONFIRM CURRENT SDK DISCLOSURES AND DESCRIBE ANY ADDITIONAL DATA HANDLING BEFORE PUBLISHING]. Our verification service stores a one-way hash of the purchase token, the associated product ID and the last verification time to prevent inconsistent transaction processing. [CONFIRM RETENTION PERIOD AND DELETION PROCESS]. The hosting provider may process IP addresses and connection metadata to deliver and protect the service. [NAME HOSTING PROVIDER, LOGGING SETTINGS, RETENTION AND ANY ADDITIONAL PROCESSORS]. We do not use purchase data for advertising or profiling children and do not sell it. diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..683819a --- /dev/null +++ b/VALIDATION.md @@ -0,0 +1,28 @@ +# Release validation — October 21, 2026 + +This is a packaging/billing-integration candidate, not a verified monetized Play release. + +| Check | Result | +|---|---| +| Original game-rule tests and new security/verifier unit tests | 21 passed | +| Original browser interaction test | 24 checks passed; no browser errors | +| Android UI contract in browser with native bridge simulated | Passed: forged/simulated ownership rejected, premium travel blocked, invalid saved premium scene reset, development controls hidden, payment disabled without verifier | +| Android release AAB build | Passed | +| Android lint release | 0 errors, 4 warnings | +| Android Java unit-test task | Completed with NO-SOURCE; this repository has no native Java unit tests | +| Bundletool 1.18.2 structural validation | Passed | +| Bundle manifest | com.lilopia.game; version 1.0.0/code 2; min API 24/target 36 | +| Merged permissions | INTERNET, ACCESS_NETWORK_STATE, com.android.vending.BILLING | +| Native shared libraries | None in build output; bundle checked separately in handoff | +| Release signing | Unsigned; no debug key fallback | +| Signing helper | Successfully signed and verified a separate disposable copy with a temporary LOCAL SIGNING TEST ONLY key; that copy/key is not delivered | +| Server production dependency audit | 0 known vulnerabilities at check time | +| Existing GitHub Pages deployment | HTTP 200; existing browser tab showed playable Community Playground; not redeployed by this task | +| Play Console inspection | Personal developer account, no apps; phone verification action present on contact page | +| Connected Android devices | None | +| Google Play checkout / real token verification | NOT RUN; no configured app/products/authorized Developer API service | +| Physical phone/tablet, Play pre-launch, Families review | NOT RUN | + +The four lint warnings concern the retained landscape orientation, enabling JavaScript for the bundled game, and missing optional monochrome themed variants of the two adaptive icons. They are not hidden by a lint baseline. Gradle also reports deprecation notices for future Gradle 9 migration; the pinned Gradle 8.14.3 build succeeds. + +No signing credentials, service account keys, live purchase tokens or payment-card details were added to source or included in deliverables. The supplied AAB keeps purchases disabled until rebuilt with a deployed HTTPS verifier. A public contact and completed privacy policy are still required before review; the policy file is explicitly a draft. See PLAY-RELEASE.md for exact Console steps and the remaining test matrix. diff --git a/android/app/build.gradle b/android/app/build.gradle index ed9b20b..4e381c6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -35,7 +35,7 @@ android { applicationId 'com.lilopia.game' minSdk 24 targetSdk 36 - versionCode (findProperty('versionCode') ?: '2').toInteger() + versionCode = providers.gradleProperty('versionCode').getOrElse('2').toInteger() versionName '1.0.0' def verifier = providers.gradleProperty('verificationUrl').getOrElse('') if (verifier && !(verifier ==~ /https:\/\/[a-zA-Z0-9.-]+(?::443)?\/[^\s"\\]*/)) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cc77426..29bb627 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ - + owned = ConcurrentHashMap.newKeySet(); private final ExecutorService worker = Executors.newSingleThreadExecutor(); private volatile boolean closed; + private boolean connecting; PlayStore(Activity activity, WebView web) { this.activity = activity; this.web = web; @@ -33,11 +34,17 @@ public final class PlayStore implements PurchasesUpdatedListener { .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) .enableAutoServiceReconnection().build(); // No verifier configured: do not offer a payment that cannot be fulfilled. - if (!BuildConfig.VERIFICATION_URL.isEmpty()) client.startConnection(new BillingClientStateListener() { + connect(); + } + private void connect() { + if (closed || connecting || BuildConfig.VERIFICATION_URL.isEmpty()) return; + connecting = true; + client.startConnection(new BillingClientStateListener() { public void onBillingSetupFinished(BillingResult result) { + connecting = false; if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) { catalog(); refresh(); } } - public void onBillingServiceDisconnected() { details.clear(); notifyWeb("Store disconnected. Please try again."); } + public void onBillingServiceDisconnected() { connecting = false; details.clear(); notifyWeb("Store disconnected. Please try again."); } }); } @JavascriptInterface public boolean owns(String id) { return owned.contains(id); } @@ -84,7 +91,9 @@ private void catalog() { }); } void refresh() { - if (!client.isReady()) { notifyWeb("Store is unavailable. Connect to Google Play and try again."); return; } + if (BuildConfig.VERIFICATION_URL.isEmpty()) return; + if (!client.isReady()) { connect(); notifyWeb("Connecting to Google Play. Please try again shortly."); return; } + if (details.isEmpty()) catalog(); client.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(), (result, purchases) -> { if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) { notifyWeb("Could not restore purchases. Please try again."); return; } diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..b853a8d --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index d4081da..9251069 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -5,3 +5,4 @@ networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 diff --git a/tests/e2e/android-release.js b/tests/e2e/android-release.js index 01e99b2..e706533 100644 --- a/tests/e2e/android-release.js +++ b/tests/e2e/android-release.js @@ -23,8 +23,20 @@ const { pathToFileURL } = require('node:url'); }); assert.equal(await page.evaluate(() => L.game.sceneId()), 'home/living'); assert.equal(await page.evaluate(() => L.logic.isOwned(L.game.state(), 'exp_beach_vacation')), false); + await page.click('#setBtn'); + assert.equal(await page.getByText('Development only', { exact: true }).count(), 0); + await page.getByRole('button', { name: 'Store and purchases' }).click(); + const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + const question = await page.locator('.gateq').innerText(); + const match = question.match(/What is (\w+) times (\w+)\?/); + await page.locator('#gateAnswer').fill(String(words.indexOf(match[1]) * words.indexOf(match[2]))); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + const buttons = page.getByRole('button', { name: 'Buy with Google Play', exact: true }); + assert.ok(await buttons.count() >= 3); + for (const button of await buttons.all()) assert.equal(await button.isEnabled(), false); + assert.equal(await page.getByRole('button', { name: 'Buy (simulated)', exact: true }).count(), 0); assert.deepEqual(errors, []); - console.log('PASS Android rejects simulated grant, blocks premium travel, and sanitizes forged saved scene; no browser errors'); + console.log('PASS Android rejects simulated grant, blocks premium travel, sanitizes forged saved scene, hides development controls, and disables payment without verifier; no browser errors'); await page.screenshot({ path: '../android-ui-contract.png' }); } finally { await browser.close(); } })().catch(e => { console.error(e); process.exit(1); }); From 7327158c01b4515fa30a4e585e121f40b0b24d47 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:12 -0500 Subject: [PATCH 04/11] Run Android release validation on PR changes --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b0c55e9..13651b6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,6 +1,8 @@ name: Android release candidate on: workflow_dispatch: + pull_request: + branches: [main] permissions: contents: read jobs: From 67bad390a172ff293e0db427bcee6560a377f648 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:40:57 -0500 Subject: [PATCH 05/11] Revise privacy policy draft details Updated effective date, developer name, and support email in the privacy policy draft. --- PRIVACY-POLICY-DRAFT.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/PRIVACY-POLICY-DRAFT.md b/PRIVACY-POLICY-DRAFT.md index fd3113c..a84e000 100644 --- a/PRIVACY-POLICY-DRAFT.md +++ b/PRIVACY-POLICY-DRAFT.md @@ -2,9 +2,10 @@ **Not ready to publish: public contact, hosting configuration, retention schedule and release date must be completed.** -Effective date: [FINAL RELEASE DATE] +Effective date: 10/26/2026 -LILOPIA is a pretend-play game for children ages 3–12, published by [CONFIRMED PUBLIC DEVELOPER NAME]. Questions and privacy requests: [PUBLIC SUPPORT/PRIVACY EMAIL]. +LILOPIA is a pretend-play game for children ages 3–12, published by: Amber @ UnFiltered Ink +Questions and privacy requests: support@lilopia.com The game does not require an account, contain public chat, show advertisements, use advertising identifiers, track location, or include analytics or social media SDKs. Characters, household names, preferences and world progress are stored locally on the device. We do not transmit that saved world to a server. You can delete it through Android Settings > Apps > LILOPIA > Storage > Clear storage. Uninstalling also removes local progress. Purchases may be restored separately through Google Play. @@ -12,6 +13,6 @@ When purchases are enabled, optional expansion packs are offered through a grown Our verification service stores a one-way hash of the purchase token, the associated product ID and the last verification time to prevent inconsistent transaction processing. [CONFIRM RETENTION PERIOD AND DELETION PROCESS]. The hosting provider may process IP addresses and connection metadata to deliver and protect the service. [NAME HOSTING PROVIDER, LOGGING SETTINGS, RETENTION AND ANY ADDITIONAL PROCESSORS]. We do not use purchase data for advertising or profiling children and do not sell it. -Parents can contact [PUBLIC SUPPORT/PRIVACY EMAIL] for questions about purchase verification records, access or deletion. Please do not send a child's full name, birth date, payment-card information or purchase tokens by email. [DESCRIBE SECURE REQUEST VERIFICATION AND THE RIGHTS/APPEAL PROCESS REQUIRED IN ACTUAL DISTRIBUTION REGIONS]. +Parents can contact support@lilopia.com for questions about purchase verification records, access or deletion. Please do not send a child's full name, birth date, payment-card information or purchase tokens by email. [DESCRIBE SECURE REQUEST VERIFICATION AND THE RIGHTS/APPEAL PROCESS REQUIRED IN ACTUAL DISTRIBUTION REGIONS]. This draft describes the supplied implementation. The publisher must confirm the final deployed behavior, service providers, logging, retention, geographic requirements and contact details before publication. A grown-up gate is a purchase safety feature; it is not proof of parental legal consent. From 5a6e5b7fdc49d6c7968c585d434727655ab92f41 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:45:50 -0500 Subject: [PATCH 06/11] Update PRIVACY-POLICY-DRAFT.md From 4282960236006d5779817f6233dc76906d032e3d Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:58:40 -0500 Subject: [PATCH 07/11] ci: validate current game in Android candidate --- .github/workflows/android.yml | 12 ++++++++++++ VALIDATION.md | 11 ++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 13651b6..48f9c37 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,6 +20,18 @@ jobs: - uses: android-actions/setup-android@v3 - run: sdkmanager 'platforms;android-36' 'build-tools;35.0.0' - run: node --test tests/*.test.js + - name: Install Playwright Chromium for browser coverage + run: | + npm install --no-save --package-lock=false playwright@1.56.1 + npx playwright install --with-deps chromium + - name: Run game interaction tests, including zoom + run: | + mkdir -p /tmp/lilopia-e2e + node tests/e2e/interactions.js "$GITHUB_WORKSPACE/index.html" /tmp/lilopia-e2e + - name: Run screen and save/continue checks + run: node tests/e2e/screens.js "$GITHUB_WORKSPACE/index.html" /tmp/lilopia-e2e + - name: Run Android release UI contract + run: node tests/e2e/android-release.js - run: bash android/gradlew -p android bundleRelease lintRelease testReleaseUnitTest --console=plain - uses: actions/upload-artifact@v4 with: diff --git a/VALIDATION.md b/VALIDATION.md index 683819a..632faa6 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,4 +1,4 @@ -# Release validation — October 21, 2026 +# Release validation — historical baseline captured October 21, 2026 This is a packaging/billing-integration candidate, not a verified monetized Play release. @@ -26,3 +26,12 @@ This is a packaging/billing-integration candidate, not a verified monetized Play The four lint warnings concern the retained landscape orientation, enabling JavaScript for the bundled game, and missing optional monochrome themed variants of the two adaptive icons. They are not hidden by a lint baseline. Gradle also reports deprecation notices for future Gradle 9 migration; the pinned Gradle 8.14.3 build succeeds. No signing credentials, service account keys, live purchase tokens or payment-card details were added to source or included in deliverables. The supplied AAB keeps purchases disabled until rebuilt with a deployed HTTPS verifier. A public contact and completed privacy policy are still required before review; the policy file is explicitly a draft. See PLAY-RELEASE.md for exact Console steps and the remaining test matrix. + + +## Post-October 26 candidate status — October 29, 2026 + +The Android source in this branch includes the October 26 zoom implementation, four added pet species (tiger, kangaroo, monkey and elephant), and the corrected settings cog. The October 21 results above predate those gameplay changes and are historical evidence only; they do not prove that the current source built successfully or passed browser/device checks. + +The Android CI workflow now runs the existing Playwright interaction suite (including zoom), screen/save checks, and Android UI contract check before producing an unsigned AAB. The run triggered by this update must pass before treating the updated bundle as validated. No Play-distributed or physical-device test is recorded here. + +The candidate still has no upload-key signature and deliberately leaves purchases disabled because no deployed HTTPS verifier is configured. Internal Play installation still requires the owner to complete Console account/app setup, sign the validated AAB with a protected upload key, create the internal test release, and install through the tester opt-in link. Do not use this candidate for checkout testing. From f066cf252fd0f715560555689ad526eac0c3dd58 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:59:38 -0500 Subject: [PATCH 08/11] docs: clarify Android test candidate and release checks --- README.md | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 722e37e..8039330 100644 --- a/README.md +++ b/README.md @@ -42,39 +42,17 @@ LILOPIA is an installable web app (a PWA). It can be added to a phone's home scr Any static web host works too: upload `index.html`, `manifest.webmanifest`, `sw.js`, `src/`, `assets/`, `fonts/` and `icons/`. The service worker needs HTTPS (or `localhost`); opening `index.html` straight from disk still works, just without offline install. -## Android app (prototype) +## Android app -`android/` is a native Android project that wraps this same game, so there is one copy of the game to maintain. At build time it copies `index.html`, `src/`, `assets/` and `fonts/` into the APK. +The Android Play candidate is prepared in [PR #4](https://github.com/nugglebug87/Lilopia/pull/4). It wraps the bundled game files in a native Android app and keeps gameplay offline. -- **Offline and private:** the app asks for **no permissions at all**, not even internet. The game is served from inside the APK, and the app refuses to open any other website. -- **Landscape only**, full screen, with the system bars hidden. On sideways phones the dock folds down to its tabs to give the world more room. -- **Back button** closes the top screen (a menu, the map, a selection, decorate mode), then goes to the main menu, then leaves the app. -- **Saves** are kept on the device, written whenever the app goes to the background, and included in Android's backup. -- Package `com.lilopia.game`, version `0.1.0-prototype`, Android 7.0 (API 24) and newer, targets API 35. +- Package: `com.lilopia.game`; version 1.0.0, version code 2; minimum API 24, target API 36. +- The candidate uses landscape orientation, disables Android backup, and declares internet, network-state and Google Play Billing permissions. +- The release build is unsigned unless a protected upload key is configured; it never falls back to a debug signing key. Do not upload an unsigned bundle. +- Play purchases are deliberately unavailable until the build is configured with a deployed HTTPS purchase verifier. Keep them disabled for gameplay-only testing. +- The game save stays on the device. Clearing app storage or uninstalling removes it; there is no cloud sync or purchase restore for free gameplay data. -### Install the prototype on a phone - -1. Copy `LILOPIA-0.1.0-prototype.apk` to the phone. -2. Open it and allow installing from that app when Android asks. -3. Android may warn that the app is from an unknown developer. That is expected for a test build. - -### Build it - -Needs JDK 17 or newer and the Android SDK (Android Studio installs both). - -```sh -cd android -echo "sdk.dir=/path/to/Android/sdk" > local.properties # Android Studio writes this for you -./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk -``` - -Without a keystore the release APK is signed with the Android debug key. That is fine for test phones but Google Play will not accept it. For Play, create a keystore and add `android/keystore.properties` with `storeFile`, `storePassword`, `keyAlias` and `keyPassword` (it is git-ignored), then build an App Bundle with `./gradlew bundleRelease`. - -### What has been checked - -- The APK builds, is signed, requests no permissions and contains every game file. -- The exact files packed in the APK were run in a phone-sized, touch-enabled browser with the internet blocked: the bundled fonts load, nothing is fetched from outside, and the back-button handler steps back correctly. -- **Not yet checked on a real Android phone or emulator.** Please try it on a device, especially a low-cost one, as Section 16 asks. +See [PLAY-RELEASE.md](PLAY-RELEASE.md) for the build, Console and billing handoff, and [VALIDATION.md](VALIDATION.md) for recorded checks and limits. The current validation record distinguishes historical test results from the October 26 game updates. Play-distributed and physical-device checks still need to be completed. ## How the first build maps to Section 18 From 51f0dec0c033aca3fd373bf79f5568b353c299a4 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:00:38 -0500 Subject: [PATCH 09/11] docs: record Android CI setup failure and fix --- VALIDATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VALIDATION.md b/VALIDATION.md index 632faa6..f75aa2b 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -32,6 +32,6 @@ No signing credentials, service account keys, live purchase tokens or payment-ca The Android source in this branch includes the October 26 zoom implementation, four added pet species (tiger, kangaroo, monkey and elephant), and the corrected settings cog. The October 21 results above predate those gameplay changes and are historical evidence only; they do not prove that the current source built successfully or passed browser/device checks. -The Android CI workflow now runs the existing Playwright interaction suite (including zoom), screen/save checks, and Android UI contract check before producing an unsigned AAB. The run triggered by this update must pass before treating the updated bundle as validated. No Play-distributed or physical-device test is recorded here. +The Android CI workflow runs the existing Playwright interaction suite (including zoom), screen/save checks, and Android UI contract check before producing an unsigned AAB. Its first run on October 29 stopped during Android SDK setup because the action requested the retired `tools` package; no test or build steps ran. The workflow now requests only `platform-tools`; its rerun must pass before treating the updated bundle as validated. No Play-distributed or physical-device test is recorded here. The candidate still has no upload-key signature and deliberately leaves purchases disabled because no deployed HTTPS verifier is configured. Internal Play installation still requires the owner to complete Console account/app setup, sign the validated AAB with a protected upload key, create the internal test release, and install through the tester opt-in link. Do not use this candidate for checkout testing. From 60fd046e3ac7cc9c02bb76cdaebc715918d993a6 Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:00:39 -0500 Subject: [PATCH 10/11] ci: install supported Android SDK packages --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 48f9c37..2665294 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -18,6 +18,8 @@ jobs: distribution: temurin java-version: 17 - uses: android-actions/setup-android@v3 + with: + packages: platform-tools - run: sdkmanager 'platforms;android-36' 'build-tools;35.0.0' - run: node --test tests/*.test.js - name: Install Playwright Chromium for browser coverage From 5f317cbf901816e62708db1d3410e0c533e6f12a Mon Sep 17 00:00:00 2001 From: Amber Rose <254177356+nugglebug87@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:04:25 -0500 Subject: [PATCH 11/11] docs: record successful October 29 Android candidate validation --- VALIDATION.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/VALIDATION.md b/VALIDATION.md index f75aa2b..aadea7a 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -32,6 +32,20 @@ No signing credentials, service account keys, live purchase tokens or payment-ca The Android source in this branch includes the October 26 zoom implementation, four added pet species (tiger, kangaroo, monkey and elephant), and the corrected settings cog. The October 21 results above predate those gameplay changes and are historical evidence only; they do not prove that the current source built successfully or passed browser/device checks. -The Android CI workflow runs the existing Playwright interaction suite (including zoom), screen/save checks, and Android UI contract check before producing an unsigned AAB. Its first run on October 29 stopped during Android SDK setup because the action requested the retired `tools` package; no test or build steps ran. The workflow now requests only `platform-tools`; its rerun must pass before treating the updated bundle as validated. No Play-distributed or physical-device test is recorded here. +The Android CI workflow runs the Playwright interaction suite (including zoom), screen/save checks, Android UI contract check, and release build before producing an unsigned AAB. The first two October 29 attempts stopped during Android SDK setup because the action requested the retired `tools` package; no tests ran. The workflow now requests only `platform-tools`. No Play-distributed or physical-device test is recorded here. The candidate still has no upload-key signature and deliberately leaves purchases disabled because no deployed HTTPS verifier is configured. Internal Play installation still requires the owner to complete Console account/app setup, sign the validated AAB with a protected upload key, create the internal test release, and install through the tester opt-in link. Do not use this candidate for checkout testing. + + +### October 29, 2026 CI run + +Run [#40926743738](https://github.com/nugglebug87/Lilopia/actions/runs/40926743738) on commit `60fd046e3ac7cc9c02bb76cdaebc715918d993a6` completed successfully: + +- Node unit/security tests: 22 passed, 0 failed. +- Browser interaction checks: 35 passed, including zoom buttons, pan, wheel, pinch, selection, drag/drop and zoom reset. +- Screen/save-and-continue checks: passed. +- Android UI contract: passed; simulated and forged ownership rejected, premium travel blocked, development controls hidden, and purchases disabled without a verifier. +- Android `bundleRelease`, `lintRelease` and `testReleaseUnitTest`: passed. The native Java test task reports `NO-SOURCE`. +- Unsigned candidate AAB and lint report uploaded as artifact `lilopia-unsigned-release-candidate` (artifact ID `12919514463`). Download: https://github.com/nugglebug87/Lilopia/actions/runs/40926743738/artifacts/12919514463 + +This run validates the updated source in CI; it does not establish a Play-distributed install, physical Android device result, real checkout, or signature. The AAB remains unsigned and must stay out of Play until signed with the owner's protected upload key. Purchases remain disabled until the verifier is deployed and configured.