diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..2665294 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,43 @@ +name: Android release candidate +on: + workflow_dispatch: + pull_request: + branches: [main] +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 + 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 + 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: + name: lilopia-unsigned-release-candidate + path: | + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/lint-results-release.html 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/PLAY-RELEASE.md b/PLAY-RELEASE.md new file mode 100644 index 0000000..faa4817 --- /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. +- 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 + +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..a84e000 --- /dev/null +++ b/PRIVACY-POLICY-DRAFT.md @@ -0,0 +1,18 @@ +# 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: 10/26/2026 + +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. + +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. + +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. 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 diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..aadea7a --- /dev/null +++ b/VALIDATION.md @@ -0,0 +1,51 @@ +# Release validation — historical baseline captured 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. + + +## 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 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. diff --git a/android/app/build.gradle b/android/app/build.gradle index 821e3bd..e2317a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,7 +18,7 @@ def copyGame = tasks.register('copyGame', Sync) { return inPwa ? null : line } } - from(gameRoot) { include 'src/**', 'assets/**', 'fonts/**', 'privacy-policy.html', 'terms-of-use.html' } + from(gameRoot) { include 'src/**', 'assets/**', 'fonts/**', 'privacy-policy.html', 'terms-of-use.html', 'privacy-policy.html', 'terms-of-use.html' } into gameAssets.map { it.dir('game') } } @@ -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' + targetSdk 36 + 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"\\]*/)) { + throw new GradleException('verificationUrl must be an HTTPS endpoint without quotes or escapes') + } + buildConfigField 'String', 'VERIFICATION_URL', '"' + verifier + '"' } signingConfigs { @@ -53,11 +58,12 @@ android { buildTypes { release { minifyEnabled false - // Release builds without an upload key remain unsigned, never debug-signed. + // 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 } @@ -72,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..29bb627 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,13 @@ - + + = 33) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::handleGameBack); + } hideSystemBars(); } @@ -77,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); }); @@ -93,6 +113,7 @@ protected void onPause() { protected void onResume() { super.onResume(); web.onResume(); + if (store != null) store.refresh(); hideSystemBars(); } @@ -110,6 +131,8 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { @Override protected void onDestroy() { + if (store != null) store.close(); + web.removeJavascriptInterface("LilopiaAndroid"); web.destroy(); super.onDestroy(); } diff --git a/android/app/src/main/java/com/lilopia/game/PlayStore.java b/android/app/src/main/java/com/lilopia/game/PlayStore.java new file mode 100644 index 0000000..d55f4c3 --- /dev/null +++ b/android/app/src/main/java/com/lilopia/game/PlayStore.java @@ -0,0 +1,159 @@ +package com.lilopia.game; + +import android.app.Activity; +import android.app.AlertDialog; +import android.text.InputType; +import android.webkit.JavascriptInterface; +import android.webkit.WebView; +import android.widget.EditText; +import com.android.billingclient.api.*; +import org.json.JSONObject; +import java.net.URL; +import javax.net.ssl.HttpsURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +/** Native Play billing. Only a positive response from the configured verifier grants access. */ +public final class PlayStore implements PurchasesUpdatedListener { + private static final Set 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; + private boolean connecting; + + 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. + 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() { connecting = false; 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) { + 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; } + 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.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."); + }).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 (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; } + // 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/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/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/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/privacy-policy.html b/privacy-policy.html index 1d84a06..fbbcfa8 100644 --- a/privacy-policy.html +++ b/privacy-policy.html @@ -13,7 +13,7 @@

Information handled by the game

Purchases and advertising

-

The current game includes simulated purchase flows; they do not process real payments. If real in-app purchases are enabled in a future release, payments will be handled by the applicable app store. We will update this policy to describe any purchase identifiers or verification services used before enabling real transactions.

+

The web game includes simulated purchase flows; they do not process real payments. The Android test build has purchases disabled and does not complete real payments. If real in-app purchases are enabled in a future release, payments will be handled by the applicable app store. We will update this policy to describe any purchase identifiers or verification services used before enabling real transactions.

The current game does not display advertisements. If advertising is added later, we will review the advertising provider's data practices, apply child-appropriate safeguards, and update this policy before release. We will not describe the game as ad-free if advertising is enabled.

diff --git a/privacy-policy.md b/privacy-policy.md index c8fc37c..dd34a72 100644 --- a/privacy-policy.md +++ b/privacy-policy.md @@ -12,7 +12,7 @@ We do not intentionally collect children's personal information through the curr ## Purchases and advertising -The current game includes simulated purchase flows; they do **not** process real payments. If real in-app purchases are enabled in a future release, payments will be handled by the applicable app store. We will update this policy to describe any purchase identifiers or verification services used before enabling real transactions. +The web game includes simulated purchase flows; they do **not** process real payments. The Android test build has purchases disabled and does not complete real payments. If real in-app purchases are enabled in a future release, payments will be handled by the applicable app store. We will update this policy to describe any purchase identifiers or verification services used before enabling real transactions. The current game does not display advertisements. If advertising is added later, we will review the advertising provider's data practices, apply child-appropriate safeguards, and update this policy before release. We will not describe the game as ad-free if advertising is enabled. 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 fb2a685..f642567 100644 --- a/src/game.js +++ b/src/game.js @@ -146,6 +146,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 = ''; setViewNow(1, 0, 0); emit('scene'); emit('coins'); emit('bag'); @@ -579,7 +584,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 874283e..a36f429 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 ee9f4bf..a045660 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 = { @@ -535,13 +546,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); @@ -576,28 +587,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); } @@ -605,7 +617,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); @@ -633,11 +645,13 @@ 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); + } } const legal = el('div', 'set'); for (const [label, path] of [['Privacy Policy', 'privacy-policy.html'], ['Terms of Use', 'terms-of-use.html']]) { @@ -648,7 +662,7 @@ function openSettings(fromTitle) { legal.appendChild(link); } body.appendChild(el('p', 'sec', 'Legal')); body.appendChild(legal); - 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/terms-of-use.html b/terms-of-use.html index 8c60068..9385a24 100644 --- a/terms-of-use.html +++ b/terms-of-use.html @@ -17,7 +17,7 @@

3. Free and premium content

Basic gameplay is intended to be free. Some additional locations, items, cosmetics, seasonal content, or expansion packs may be displayed as locked until purchased. Planned expansion packs are priced at US $5.99 each, and a planned one-time ad-removal purchase is US $3.99, where offered. Prices, taxes, availability, and regional currency conversions will be shown by the relevant app store before payment.

-

Important: The current game uses simulated purchase flows and does not process real payments. No purchase is completed merely by interacting with the current simulated store. Real-money purchases will be offered only after app-store billing is properly implemented and enabled.

+

Important: The web game uses simulated purchase flows. The Android test build has purchases disabled. Neither completes real payments. No purchase is completed merely by interacting with the current simulated store. Real-money purchases will be offered only after app-store billing is properly implemented and enabled.

When real purchases become available, eligible permanent purchases will unlock the described content in accordance with the purchase terms shown at checkout. Restoration across devices may depend on the store account and supported verification features. Do not assume that locally saved simulated ownership transfers between devices.

diff --git a/terms-of-use.md b/terms-of-use.md index b7109b5..9e67137 100644 --- a/terms-of-use.md +++ b/terms-of-use.md @@ -16,7 +16,7 @@ We may fix bugs, add or remove features, adjust content, or temporarily interrup Basic gameplay is intended to be free. Some additional locations, items, cosmetics, seasonal content, or expansion packs may be displayed as locked until purchased. Planned expansion packs are priced at **US $5.99 each**, and a planned one-time ad-removal purchase is **US $3.99**, where offered. Prices, taxes, availability, and regional currency conversions will be shown by the relevant app store before payment. -**Important:** The current game uses simulated purchase flows and does not process real payments. No purchase is completed merely by interacting with the current simulated store. Real-money purchases will be offered only after app-store billing is properly implemented and enabled. +**Important:** The web game uses simulated purchase flows. The Android test build has purchases disabled. Neither completes real payments. No purchase is completed merely by interacting with the current simulated store. Real-money purchases will be offered only after app-store billing is properly implemented and enabled. When real purchases become available, eligible permanent purchases will unlock the described content in accordance with the purchase terms shown at checkout. Restoration across devices may depend on the store account and supported verification features. Do not assume that locally saved simulated ownership transfers between devices. @@ -60,7 +60,7 @@ We may update these terms as LILOPIA develops. The latest version will be posted For questions about LILOPIA or these terms, contact: -**Unfiltered Ink** +**Unfiltered Ink** **Email:** Unfilteredinink@gmail.com These terms are intended for general publication and should be reviewed against the final Android release, enabled services, and applicable law before Google Play launch. diff --git a/tests/e2e/android-release.js b/tests/e2e/android-release.js new file mode 100644 index 0000000..e706533 --- /dev/null +++ b/tests/e2e/android-release.js @@ -0,0 +1,42 @@ +// 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); + 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, 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); }); 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 () => {})); +}); 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() +}