diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..5d38745
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,39 @@
+name: android-build
+
+on:
+ pull_request:
+ paths:
+ - 'mobile/android/**'
+ - 'engine/**'
+ - 'render/**'
+ - '.github/workflows/android.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
+
+jobs:
+ assemble-debug:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '17'
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ - uses: android-actions/setup-android@v4
+ with:
+ packages: 'platform-tools platforms;android-36 build-tools;36.0.0'
+ - uses: gradle/actions/setup-gradle@v6
+ with:
+ gradle-version: '9.4.1'
+ - name: Assemble debug APK
+ working-directory: mobile/android
+ run: gradle --no-daemon :app:assembleDebug
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c562ec8..f9a860b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
diff --git a/mobile/android/README.md b/mobile/android/README.md
new file mode 100644
index 0000000..ffb07f5
--- /dev/null
+++ b/mobile/android/README.md
@@ -0,0 +1,56 @@
+# SCARED SACRED Android shell
+
+This module wraps the existing tested Python game rather than rewriting it.
+Chaquopy packages `engine/` and `render/` directly; `mobile_bridge.py` starts
+the same table server on `127.0.0.1`, and the Android WebView is only a shell.
+
+## Store boundary
+
+RevenueCat exists here to satisfy a real product boundary without selling game
+power, Witness Accounts, or access to the ruleset.
+
+- entitlement: `sacred_table` by default
+- product: configure one Google Play one-time product as **non-consumable**
+- offering: place that product in the current RevenueCat offering
+- effect: permanent cosmetic Sacred Table skin only
+- restore: exposed in the native shell
+
+Witness Accounts remain giver-owned, table-bound, and unsellable per base canon.
+All playable mechanics remain available without purchase.
+
+## Build
+
+The build intentionally pins the newest combination currently claimed compatible
+by both Android and Chaquopy rather than using incompatible latest versions:
+
+- Android Gradle Plugin 9.2.1
+- Gradle 9.4.1
+- Chaquopy 17.0.0
+- Python 3.12
+- compile/target API 36
+- RevenueCat Android SDK 10.15.1
+
+From `mobile/android`:
+
+```sh
+gradle :app:assembleDebug
+```
+
+The public RevenueCat SDK key is injected at build time and is not committed:
+
+```sh
+gradle :app:assembleDebug \
+ -PREVENUECAT_API_KEY=goog_xxx \
+ -PREVENUECAT_ENTITLEMENT_ID=sacred_table
+```
+
+With no key, the game still builds and runs; the purchase controls remain
+visibly unconfigured rather than pretending monetization works.
+
+## Store release boundary
+
+A Shipaton-eligible release still requires the external store configuration:
+Google Play app, one-time product, RevenueCat project/offering/entitlement,
+signed AAB, store listing, judge access, icon, screenshot, and demo video.
+
+hmmm — commerce belongs around the table, not between a witness and the room.
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
new file mode 100644
index 0000000..1fc36c4
--- /dev/null
+++ b/mobile/android/app/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("com.chaquo.python")
+}
+
+fun quoted(value: String): String = "\"" + value
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"") + "\""
+
+val revenueCatApiKey = providers.gradleProperty("REVENUECAT_API_KEY")
+ .orElse(providers.environmentVariable("REVENUECAT_API_KEY"))
+ .getOrElse("")
+val revenueCatEntitlement = providers.gradleProperty("REVENUECAT_ENTITLEMENT_ID")
+ .orElse(providers.environmentVariable("REVENUECAT_ENTITLEMENT_ID"))
+ .getOrElse("sacred_table")
+
+android {
+ namespace = "org.theinterdependency.scaredsacred"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "org.theinterdependency.scaredsacred"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+
+ ndk {
+ abiFilters += listOf("arm64-v8a", "x86_64")
+ }
+
+ buildConfigField("String", "REVENUECAT_API_KEY", quoted(revenueCatApiKey))
+ buildConfigField("String", "REVENUECAT_ENTITLEMENT_ID", quoted(revenueCatEntitlement))
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+chaquopy {
+ defaultConfig {
+ version = "3.12"
+ }
+ sourceSets {
+ getByName("main") {
+ srcDir("src/main/python")
+ srcDir("../../../engine")
+ srcDir("../../../render")
+ }
+ }
+}
+
+dependencies {
+ implementation("com.revenuecat.purchases:purchases:10.15.1")
+}
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a8e9e31
--- /dev/null
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java b/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java
new file mode 100644
index 0000000..6be5044
--- /dev/null
+++ b/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java
@@ -0,0 +1,247 @@
+package org.theinterdependency.scaredsacred;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.chaquo.python.PyObject;
+import com.chaquo.python.Python;
+import com.revenuecat.purchases.CustomerInfo;
+import com.revenuecat.purchases.EntitlementInfo;
+import com.revenuecat.purchases.Offering;
+import com.revenuecat.purchases.Offerings;
+import com.revenuecat.purchases.Package;
+import com.revenuecat.purchases.PurchaseParams;
+import com.revenuecat.purchases.Purchases;
+import com.revenuecat.purchases.PurchasesConfiguration;
+import com.revenuecat.purchases.PurchasesError;
+import com.revenuecat.purchases.interfaces.PurchaseCallback;
+import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback;
+import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback;
+import com.revenuecat.purchases.models.StoreTransaction;
+
+import java.util.List;
+
+public final class MainActivity extends Activity {
+ private static final int GAME_PORT = 5300;
+
+ private WebView webView;
+ private TextView purchaseStatus;
+ private Button purchaseButton;
+ private Button restoreButton;
+ private Package sacredTablePackage;
+ private boolean sacredTableOwned = false;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(buildUi());
+ startGameServer();
+ configureRevenueCat();
+ }
+
+ private View buildUi() {
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setBackgroundColor(Color.BLACK);
+
+ webView = new WebView(this);
+ webView.setBackgroundColor(Color.BLACK);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.getSettings().setDomStorageEnabled(false);
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ applySacredTableSkin();
+ }
+ });
+ root.addView(webView, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
+
+ LinearLayout storeBar = new LinearLayout(this);
+ storeBar.setOrientation(LinearLayout.VERTICAL);
+ storeBar.setPadding(12, 8, 12, 12);
+
+ purchaseStatus = new TextView(this);
+ purchaseStatus.setTextColor(Color.LTGRAY);
+ purchaseStatus.setText("SACRED TABLE: checking ownership");
+ storeBar.addView(purchaseStatus);
+
+ LinearLayout buttons = new LinearLayout(this);
+ buttons.setOrientation(LinearLayout.HORIZONTAL);
+
+ purchaseButton = new Button(this);
+ purchaseButton.setText("UNLOCK SACRED TABLE");
+ purchaseButton.setEnabled(false);
+ purchaseButton.setOnClickListener(v -> purchaseSacredTable());
+ buttons.addView(purchaseButton, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+
+ restoreButton = new Button(this);
+ restoreButton.setText("RESTORE");
+ restoreButton.setEnabled(false);
+ restoreButton.setOnClickListener(v -> restorePurchases());
+ buttons.addView(restoreButton, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+
+ storeBar.addView(buttons);
+ root.addView(storeBar);
+ return root;
+ }
+
+ private void startGameServer() {
+ new Thread(() -> {
+ try {
+ Python py = Python.getInstance();
+ PyObject bridge = py.getModule("mobile_bridge");
+ bridge.callAttr("start_server", GAME_PORT, 3);
+ runOnUiThread(() -> webView.loadUrl(
+ "http://127.0.0.1:" + GAME_PORT + "/"));
+ } catch (Exception error) {
+ runOnUiThread(() -> purchaseStatus.setText(
+ "GAME START FAILED: " + error.getClass().getSimpleName()));
+ }
+ }, "politics-python-start").start();
+ }
+
+ private void configureRevenueCat() {
+ String apiKey = BuildConfig.REVENUECAT_API_KEY;
+ if (apiKey == null || apiKey.isEmpty()) {
+ purchaseStatus.setText("SACRED TABLE: RevenueCat key not configured");
+ return;
+ }
+
+ Purchases.configure(new PurchasesConfiguration.Builder(
+ getApplicationContext(), apiKey).build());
+ restoreButton.setEnabled(true);
+ refreshCustomerInfo();
+ loadOffering();
+ }
+
+ private void refreshCustomerInfo() {
+ Purchases.getSharedInstance().getCustomerInfo(new ReceiveCustomerInfoCallback() {
+ @Override
+ public void onReceived(CustomerInfo customerInfo) {
+ setOwnership(customerInfo);
+ }
+
+ @Override
+ public void onError(PurchasesError error) {
+ purchaseStatus.setText("SACRED TABLE: ownership unavailable");
+ }
+ });
+ }
+
+ private void loadOffering() {
+ Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() {
+ @Override
+ public void onReceived(Offerings offerings) {
+ Offering current = offerings.getCurrent();
+ List packages = current == null
+ ? null : current.getAvailablePackages();
+ sacredTablePackage = (packages == null || packages.isEmpty())
+ ? null : packages.get(0);
+ purchaseButton.setEnabled(!sacredTableOwned && sacredTablePackage != null);
+ if (!sacredTableOwned && sacredTablePackage == null) {
+ purchaseStatus.setText("SACRED TABLE: configure a RevenueCat offering");
+ }
+ }
+
+ @Override
+ public void onError(PurchasesError error) {
+ purchaseStatus.setText("SACRED TABLE: offering unavailable");
+ }
+ });
+ }
+
+ private void purchaseSacredTable() {
+ if (sacredTablePackage == null || sacredTableOwned) {
+ return;
+ }
+ purchaseButton.setEnabled(false);
+ PurchaseParams params = new PurchaseParams.Builder(
+ this, sacredTablePackage).build();
+ Purchases.getSharedInstance().purchase(params, new PurchaseCallback() {
+ @Override
+ public void onCompleted(StoreTransaction storeTransaction,
+ CustomerInfo customerInfo) {
+ setOwnership(customerInfo);
+ }
+
+ @Override
+ public void onError(PurchasesError error, boolean userCancelled) {
+ purchaseButton.setEnabled(!userCancelled && sacredTablePackage != null);
+ if (userCancelled) {
+ purchaseStatus.setText("SACRED TABLE: purchase cancelled");
+ purchaseButton.setEnabled(true);
+ } else {
+ purchaseStatus.setText("SACRED TABLE: purchase failed");
+ purchaseButton.setEnabled(true);
+ }
+ }
+ });
+ }
+
+ private void restorePurchases() {
+ restoreButton.setEnabled(false);
+ Purchases.getSharedInstance().restorePurchases(new ReceiveCustomerInfoCallback() {
+ @Override
+ public void onReceived(CustomerInfo customerInfo) {
+ restoreButton.setEnabled(true);
+ setOwnership(customerInfo);
+ }
+
+ @Override
+ public void onError(PurchasesError error) {
+ restoreButton.setEnabled(true);
+ purchaseStatus.setText("SACRED TABLE: restore failed");
+ }
+ });
+ }
+
+ private void setOwnership(CustomerInfo customerInfo) {
+ EntitlementInfo entitlement = customerInfo.getEntitlements()
+ .get(BuildConfig.REVENUECAT_ENTITLEMENT_ID);
+ sacredTableOwned = entitlement != null && entitlement.isActive();
+ if (sacredTableOwned) {
+ purchaseStatus.setText("SACRED TABLE: owned permanently");
+ purchaseButton.setText("SACRED TABLE OWNED");
+ purchaseButton.setEnabled(false);
+ } else {
+ purchaseStatus.setText("SACRED TABLE: optional cosmetic unlock");
+ purchaseButton.setText("UNLOCK SACRED TABLE");
+ purchaseButton.setEnabled(sacredTablePackage != null);
+ }
+ applySacredTableSkin();
+ }
+
+ private void applySacredTableSkin() {
+ if (webView == null || !sacredTableOwned) {
+ return;
+ }
+ String js = "(() => {"
+ + "document.body.style.background='#160d18';"
+ + "document.body.style.color='#f1e8d2';"
+ + "document.querySelectorAll('.card,.st').forEach(e=>{"
+ + "e.style.borderColor='#b99b63';e.style.background='#201522';});"
+ + "document.querySelectorAll('#tracks,#identity,#reaction').forEach(e=>"
+ + "e.style.letterSpacing='0.04em');"
+ + "})()";
+ webView.evaluateJavascript(js, null);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (webView != null) {
+ webView.destroy();
+ }
+ super.onDestroy();
+ }
+}
diff --git a/mobile/android/app/src/main/python/mobile_bridge.py b/mobile/android/app/src/main/python/mobile_bridge.py
new file mode 100644
index 0000000..1b893cc
--- /dev/null
+++ b/mobile/android/app/src/main/python/mobile_bridge.py
@@ -0,0 +1,33 @@
+"""Android bridge for the existing POLITICS table server.
+
+The mobile shell does not fork game logic. It starts the same stdlib HTTP
+table on loopback and lets Android own only lifecycle and store purchase UI.
+"""
+
+import threading
+
+import serve
+
+_SERVER = None
+_THREAD = None
+
+
+def start_server(port=5300, seats=3):
+ global _SERVER, _THREAD
+ if _SERVER is not None:
+ return int(port)
+
+ serve.TABLE = serve.Table(seats=int(seats))
+ _SERVER = serve.ThreadingHTTPServer(("127.0.0.1", int(port)), serve.H)
+ _THREAD = threading.Thread(target=_SERVER.serve_forever, daemon=True)
+ _THREAD.start()
+ return int(port)
+
+
+def stop_server():
+ global _SERVER, _THREAD
+ if _SERVER is not None:
+ _SERVER.shutdown()
+ _SERVER.server_close()
+ _SERVER = None
+ _THREAD = None
diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts
new file mode 100644
index 0000000..d43e545
--- /dev/null
+++ b/mobile/android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "9.2.1" apply false
+ id("com.chaquo.python") version "17.0.0" apply false
+}
diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties
new file mode 100644
index 0000000..bebd99f
--- /dev/null
+++ b/mobile/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.nonTransitiveRClass=true
diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts
new file mode 100644
index 0000000..f366dd2
--- /dev/null
+++ b/mobile/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "ScaredSacred"
+include(":app")