Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions .claude/skills/test-subapp-locally/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
---
name: test-subapp-locally
description: Run a local FeedTheMonster (FTM) or assessment-survey-js build inside the CRContainer Android app for end-to-end testing — redirect the deployed sub-app URL to a local dev server, set up port forwarding, link a local @curiouslearning/core, and verify the JS→native payload bridge and Firestore writes. Use whenever testing sub-app changes, the AppEventPayload bridge, analytics/metadata payloads, or any FTM/Assessment behavior against a real container build.
argument-hint: Which sub-app (FTM or Assessment), and whether you're also testing local @curiouslearning/core changes.
---

# Test a sub-app locally inside CRContainer

CRContainer loads sub-apps (FTM, Assessment) from the web URLs in its manifest. To test a
**local** sub-app build instead, the debug build rewrites the sub-app's URL to a local dev
server. This skill covers the full rig: URL redirect, port forwarding, optional local `core`
link, feature flags, and how to verify the payload bridge → Firestore.

## How the redirect works

In **debug builds only**, `WebAppsAdapter.maybeOverrideAppUrlForLocalDev()`
([WebAppsAdapter.java](../../../app/src/main/java/org/curiouslearning/container/presentation/adapters/WebAppsAdapter.java)) rewrites a sub-app URL when its host matches a configured list:

- Driven by two `BuildConfig` fields that [app/build.gradle](../../../app/build.gradle) reads from
your gitignored `local.properties`:
- `LOCAL_SUBAPP_MATCH_HOSTS` — comma-separated hosts to redirect (empty by default → no redirect)
- `LOCAL_SUBAPP_REPLACEMENT_ORIGIN` — the local origin to redirect to (e.g. `http://localhost:8080`)
- It keeps only `scheme://authority`, **drops the path** (the dev server serves the app at root),
and **preserves the query/fragment** (e.g. `?cr_lang=english`, `?data=hausa-sightwords`).
- Empty defaults live in `defaultConfig`, so release builds are never affected. Real values only
ever exist in `local.properties`, which CI does not have — so the redirect is inert in the debug
APK too. It is active **only** when you build from your own machine.

After the redirect, `WebApp.addContainerVersionToUrl()` appends `container_app_version=<versionName>`.

## Step-by-step

### 1. Find the sub-app's current host (it changes per environment)

The debug build loads its manifest from the `testing_branch` API
(`BuildConfig.API_URL` → `.../container_app_manifest/testing_branch/web_app_manifest.json`).
Fetch it and read the real `appUrl` host for your sub-app — don't assume:

```bash
curl -s "https://devcuriousreader.wpcomstaging.com/container_app_manifest/testing_branch/web_app_manifest.json" \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);(j.web_apps||j.apps||j).forEach?.(a=>{}); const arr=j.web_apps||j.apps||j; (Array.isArray(arr)?arr:[]).forEach(a=>{const u=a.appUrl||a.app_url||a.url; if(u){try{console.log(new URL(u).host,' <- ',u)}catch(e){}}})})"
```

Known hosts as of this writing (verify each time — they migrate, e.g. to S3):

| Sub-app | testing_branch host | Dev port |
|---|---|---|
| FTM | `globallit-aws-s3-static-webapp-test-us-east-2.s3.us-west-2.amazonaws.com` | 8080 |
| Assessment | `assessmentdev.curiouscontent.org` | 8081 |

(Prod/dev fallback hosts: `feedthemonster.curiouscontent.org`, `feedthemonsterdev.curiouscontent.org`, `assessment.curiouscontent.org`.)

### 2. Point the debug build at your local dev server

Set both keys in **`local.properties`** (repo root) — *not* in `build.gradle`:

```properties
localSubappMatchHosts=<host from step 1>
localSubappReplacementOrigin=http://localhost:<dev port>
```

`local.properties` is gitignored, so these never get committed and are always absent on CI. That
matters: CI builds a debug APK and **commits it to the repo** for testers
([Fastfile](../../../app/fastlane/Fastfile)), so an active redirect in a tracked file would point
every tester at their own localhost. Config here is structurally incapable of that — nothing to
remember to revert. See [local.properties.example](../../../local.properties.example) for the
copy-paste block for each sub-app.

Both keys must be set for the redirect to activate; blank or missing = no redirect.

**Use `http://localhost`, not `http://10.0.2.2`.** Sub-apps register a **service worker**, which
only works in a secure context (HTTPS or `localhost`/`127.0.0.1`). `10.0.2.2` is treated as
insecure and the SW silently fails. `localhost` is reachable from the device via `adb reverse`
(step 4) and works for both emulator and physical device.

`localhost` and `127.0.0.1` are cleartext-permitted for debug builds in
[app/src/debug/res/xml/network_security_config.xml](../../../app/src/debug/res/xml/network_security_config.xml)
(wired via the debug [AndroidManifest.xml](../../../app/src/debug/AndroidManifest.xml)
`networkSecurityConfig`). Without it `targetSdk 35` blocks the load with
`net::ERR_CLEARTEXT_NOT_PERMITTED`. Add new hosts there if you switch off loopback.

Re-sync Gradle after editing `local.properties`.

**Sub-app identity survives the redirect.** The rewritten URL no longer contains the real host or
the sub-app's name, so `WebApp` keeps a `localDevOriginalUrl` extra (set only when a redirect
happened) and resolves two things from it rather than the loaded URL:

- `isFtmApp` — otherwise false under redirect, silently disabling the monster-evolution polling.
- `AppContextKey.HOSTNAME` → `attribution.hostname` in Firestore — otherwise recorded as
`localhost`, corrupting the field you're likely verifying.

So expect the **real** sub-app host in `attribution.hostname` even while running locally.

### 3. (Optional) Link a local `@curiouslearning/core`

Only if you're also testing un-published `core` changes (the payload schema / `AndroidInterface`
live in `@curiouslearning/core`, a separate repo at `c:\CuriousLearning\core`):

```bash
cd c:/CuriousLearning/core && npm run build && npm link
cd c:/CuriousLearning/FeedTheMonsterJS && npm link @curiouslearning/core # or assessment-survey-js
```

The dev-server build log should show `../core/dist/index.js [built]`, confirming the link is used.
Rebuild `core` (`npm run build`) after each core change. Unlink later with
`npm unlink @curiouslearning/core` then `npm install`.

### 4. Start the dev server + forward the port

```bash
# FTM
cd c:/CuriousLearning/FeedTheMonsterJS && npm run dev # serves :8080
# Assessment
cd c:/CuriousLearning/assessment-survey-js && npm run dev # serves :8081

# Forward device localhost -> host dev server (re-run if the emulator/adb restarts)
adb reverse tcp:8080 tcp:8080 # match the dev port
adb reverse --list # verify
```

### 5. Enable the feature flags that gate the sub-app→native path

- **FTM**: `mr-75` (`FEATURE_ANDROID_EVENT_BUBBLE`) must be enabled for your test user, or FTM
never registers the Android strategy and nothing bubbles. Loaded via `@curiouslearning/features`
(Statsig) — see [feedTheMonster.ts](../../../../FeedTheMonsterJS/src/feedTheMonster.ts) `initAndroidModule()`.
- **Assessment**: the summary path is gated by `enableAndroidSummary`; the user-sessions path fires
when `appType === Assessment.TYPE` — see assessment `App.ts` `notifySummaryData` / completion handler.

### 6. Run the debug app and watch Logcat

Run the **debug** variant on the connected device. Open the sub-app tile and confirm the redirect:

```
WebAppsAdapter: DEBUG sub-app URL override: https://<host>/...?<query> -> http://localhost:<port>/?<query>
```

Complete a level/puzzle (FTM) or finish an assessment, then watch the bridge + handler:

```
WebApp: BRIDGE_PARSED: collection=... app_id=... cr_user_id=...
WebApp: BRIDGE_VALIDATED -> handler.handle()
AppEventHandler: Handling summary_data / user_sessions_data payload
AppEventHandler: ... saved docId=... (or "Updated summary payload")
```

Entry point: `WebAppInterface.logMessage()` in
[WebApp.java](../../../app/src/main/java/org/curiouslearning/container/WebApp.java);
storage in [DefaultAppEventPayloadHandler.java](../../../app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java).

### 7. Verify Firestore

Check the `summary_data` and `user_sessions_data` collections for the new doc, with the expected
top-level fields (`cr_user_id`, `app_id`, `collection`, `metadata`, `data`, timestamps).

## Gotchas

- **Service worker caching**: the dev build writes `sw.js` and precaches the bundle. If the WebView
serves a stale sub-app, clear the container app's storage (or uninstall/reinstall) so it refetches.
- **`adb reverse` is not persistent**: it resets when the emulator or adb server restarts. Re-run it.
- **"Invalid Host header"** from webpack-dev-server: IP/localhost hosts pass by default; if it
appears, add `allowedHosts: 'all'` to the sub-app's `devServer` config.
- **Wrong host**: if you never see the override log, the manifest host changed — redo step 1.
- **Release safety**: never rely on path in the redirect; only `scheme://authority` + query survive.

## Cleanup

Nothing to revert in tracked files — the redirect config lives only in your gitignored
`local.properties`. Just tear down the forwarding when you're done:

```bash
adb reverse --remove-all
```

Leave the `local.properties` keys in place (or comment them out) for next time.

## Key files

| File | Role |
|---|---|
| `local.properties` (gitignored) | `localSubappMatchHosts` / `localSubappReplacementOrigin` — your actual values |
| [local.properties.example](../../../local.properties.example) | tracked template documenting both keys |
| [app/build.gradle](../../../app/build.gradle) | reads those keys into `LOCAL_SUBAPP_*` `BuildConfig` fields (debug) + empty defaults |
| [WebAppsAdapter.java](../../../app/src/main/java/org/curiouslearning/container/presentation/adapters/WebAppsAdapter.java) | `maybeOverrideAppUrlForLocalDev()` redirect logic + `localDevOriginalUrl` extra |
| [app/src/debug/res/xml/network_security_config.xml](../../../app/src/debug/res/xml/network_security_config.xml) | cleartext allowlist (localhost, 127.0.0.1) |
| [WebApp.java](../../../app/src/main/java/org/curiouslearning/container/WebApp.java) | bridge entry `logMessage()`, `addContainerVersionToUrl()`, `BRIDGE_*` logs |
| [DefaultAppEventPayloadHandler.java](../../../app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java) | Firestore routing + `AppEventHandler` logs |
23 changes: 23 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ plugins {
id 'io.sentry.android.gradle' version '5.10.0'
}
def buildBranch = project.hasProperty("buildBranch") ? project.getProperty("buildBranch") : "main"

// Developer-local settings (gitignored). Absent on CI, so anything read from here is always
// empty in a distributed APK. See local.properties.example.
def localProps = new Properties()
def localPropsFile = rootProject.file("local.properties")
if (localPropsFile.exists()) {
localPropsFile.withInputStream { localProps.load(it) }
}

def apiUrl = buildBranch == "main" ?
"https://devcuriousreader.wpcomstaging.com/container_app_manifest/prod/" :
"https://devcuriousreader.wpcomstaging.com/container_app_manifest/testing_branch/"
Expand Down Expand Up @@ -45,6 +54,14 @@ android {

// ipinfo.io Lite token — same public token shipped in the assessment-survey-js web bundle
buildConfigField "String", "IPINFO_TOKEN", "\"b6268727178610\""

// Local sub-app dev redirect, consumed by WebAppsAdapter.maybeOverrideAppUrlForLocalDev().
// Empty here so release builds compile and never redirect; the debug buildType below
// fills these from the developer's own (gitignored) local.properties, which means they
// are always empty on CI -> the redirect is inert in every distributed APK.
// See local.properties.example and the test-subapp-locally skill.
buildConfigField "String", "LOCAL_SUBAPP_MATCH_HOSTS", "\"\""
buildConfigField "String", "LOCAL_SUBAPP_REPLACEMENT_ORIGIN", "\"\""
}

buildFeatures {
Expand Down Expand Up @@ -76,6 +93,12 @@ android {
debug{
testCoverageEnabled true
buildConfigField "String", "API_URL", "\"https://devcuriousreader.wpcomstaging.com/container_app_manifest/testing_branch/\""
// Local sub-app dev redirect — values come from local.properties (gitignored, never
// committed, absent on CI). Copy local.properties.example to set them up.
buildConfigField "String", "LOCAL_SUBAPP_MATCH_HOSTS",
"\"${localProps.getProperty('localSubappMatchHosts', '')}\""
buildConfigField "String", "LOCAL_SUBAPP_REPLACEMENT_ORIGIN",
"\"${localProps.getProperty('localSubappReplacementOrigin', '')}\""
}
release {
signingConfig signingConfigs.release
Expand Down
14 changes: 14 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only manifest overlay. Merged into the main manifest for debug builds only, so release
APKs never carry the network security config below.

Its sole purpose is to permit cleartext HTTP to loopback so the local sub-app redirect can load
http://localhost:<port>. See app/src/debug/res/xml/network_security_config.xml for the (loopback
only) allowlist and why it is scoped that way.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application android:networkSecurityConfig="@xml/network_security_config" />

</manifest>
21 changes: 21 additions & 0 deletions app/src/debug/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds only (this file lives in the debug source set, so release APKs are unaffected).

targetSdk 28+ denies cleartext HTTP by default, which blocks the local sub-app redirect from
loading http://localhost:<port> in the WebView (net::ERR_CLEARTEXT_NOT_PERMITTED). This permits
cleartext to loopback ONLY — every other host still requires HTTPS, even in debug.

Deliberately limited to localhost / 127.0.0.1: those are the origins the redirect uses, and they
are the only ones a browser treats as a secure context, which sub-app service workers require.
Reach the host machine's dev server through them with `adb reverse tcp:<port> tcp:<port>`
rather than adding the emulator alias 10.0.2.2 here — service workers do not register on it.

See local.properties.example and .claude/skills/test-subapp-locally/SKILL.md
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
33 changes: 33 additions & 0 deletions app/src/main/java/org/curiouslearning/container/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ProgressBar;
Expand Down Expand Up @@ -768,6 +769,13 @@ private void cachePseudoId() {

private void warmFirestoreDataSync() {
String pseudoId = prefs.getString("pseudoId", "");
// Debug-only override mirrors WebApp.initViews so a tester's custom cr_user_id syncs on container open too.
if (BuildConfig.DEBUG) {
String customUserId = prefs.getString("custom_cr_user_id", "");
if (customUserId != null && !customUserId.isEmpty()) {
pseudoId = customUserId;
}
}
summaryHandler = DefaultAppEventPayloadHandler.getInstance(pseudoId);
}
public static String convertEpochToDate(long epochMillis) {
Expand Down Expand Up @@ -916,6 +924,13 @@ private void showLanguagePopup() {
TextInputLayout textBox = dialog.findViewById(R.id.dropdown_menu);
AutoCompleteTextView autoCompleteTextView = dialog.findViewById(R.id.autoComplete);

// Debug-only: custom cr_user_id override field for testing.
final EditText customUserIdField = dialog.findViewById(R.id.custom_user_id);
if (BuildConfig.DEBUG && customUserIdField != null) {
customUserIdField.setVisibility(View.VISIBLE);
customUserIdField.setText(prefs.getString("custom_cr_user_id", ""));
}

// Ensure TextInputLayout has transparent background (Material Design can
// override XML)
textBox.setBackground(null);
Expand Down Expand Up @@ -983,6 +998,7 @@ public void onChanged(List<WebApp> webApps) {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
audioPlayer.play(MainActivity.this, R.raw.sound_button_pressed);
persistCustomUserId(customUserIdField);
String selectedDisplayName = (String) parent.getItemAtPosition(position);
selectedLanguage = languagesEnglishNameMap.get(selectedDisplayName);

Expand Down Expand Up @@ -1045,6 +1061,7 @@ public void run() {
closeButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
audioPlayer.play(MainActivity.this, R.raw.sound_button_pressed);
persistCustomUserId(customUserIdField);
textView.setVisibility(View.GONE);

// Animate close button, then trigger dropdown exit animation
Expand Down Expand Up @@ -1096,6 +1113,22 @@ public void run() {
}
}

// Debug-only: persist (or clear) the custom cr_user_id override entered in the
// language popup. No-op in release builds since the field is never shown there.
private void persistCustomUserId(EditText customUserIdField) {
if (!BuildConfig.DEBUG || customUserIdField == null) {
return;
}
String customId = customUserIdField.getText().toString().trim();
SharedPreferences.Editor editor = prefs.edit();
if (customId.isEmpty()) {
editor.remove("custom_cr_user_id");
} else {
editor.putString("custom_cr_user_id", customId);
}
editor.apply();
}

private Map<String, String> MapLanguagesEnglishName(List<WebApp> webApps) {
Map<String, String> languagesEnglishNameMap = new TreeMap<>();
for (WebApp webApp : webApps) {
Expand Down
Loading