diff --git a/app/src/main/java/org/curiouslearning/container/WebApp.java b/app/src/main/java/org/curiouslearning/container/WebApp.java index c6208f8b..d0b532fe 100644 --- a/app/src/main/java/org/curiouslearning/container/WebApp.java +++ b/app/src/main/java/org/curiouslearning/container/WebApp.java @@ -16,8 +16,6 @@ import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.ImageView; -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; import androidx.appcompat.app.AlertDialog; import org.curiouslearning.container.firebase.AnalyticsUtils; @@ -28,11 +26,7 @@ import org.curiouslearning.container.core.context.AppContext; import org.curiouslearning.container.core.context.AppContextKey; -import org.curiouslearning.container.core.subapp.payload.AppEventPayload; -import org.curiouslearning.container.core.subapp.validation.AppEventPayloadValidator; -import org.curiouslearning.container.core.subapp.validation.ValidationResult; -import org.curiouslearning.container.core.subapp.handler.AppEventPayloadHandler; -import org.curiouslearning.container.core.subapp.handler.DefaultAppEventPayloadHandler; +import org.curiouslearning.container.core.subapp.emitter.AppEventEmitter; public class WebApp extends BaseActivity { @@ -263,16 +257,14 @@ public void onClick(DialogInterface dialog, int id) { public class WebAppInterface { private Context mContext; - private final Gson gson = new Gson(); - private final AppEventPayloadValidator validator = - new AppEventPayloadValidator(); - private final AppEventPayloadHandler handler; + private final AppEventEmitter emitter; WebAppInterface(Context context) { mContext = context; - // Shared process-level instance (also warmed on container open in MainActivity) — reused here so - // the container and every sub-app write through one handler against one warmed Firestore cache. - handler = DefaultAppEventPayloadHandler.getInstance(pseudoId); + // Resolves to the shared process-level handler (also warmed on container open in MainActivity) + // — so the container and every sub-app write through one handler against one warmed Firestore + // cache. Validation and JSON parsing live in the emitter, shared with Java-side callers. + emitter = AppEventEmitter.forUser(pseudoId); } @JavascriptInterface @@ -307,31 +299,9 @@ public void closeWebView() { @JavascriptInterface public void logMessage(String payloadJson) { - - try { - if (payloadJson == null || payloadJson.trim().isEmpty()) { - Log.e("WebApp", "Rejected payload: empty JSON"); - return; - } - - AppEventPayload payload = - gson.fromJson(payloadJson, AppEventPayload.class); - - ValidationResult result = validator.validate(payload); - - if (!result.isValid) { - Log.e("WebApp", - "Payload rejected: " + result.errorMessage); - return; - } - - handler.handle(payload); - - } catch (JsonSyntaxException e) { - Log.e("WebApp", "Invalid JSON payload", e); - } catch (Exception e) { - Log.e("WebApp", "Unexpected error handling payload", e); - } + // Guards, parsing, validation and dispatch all live in the emitter, so a JS-originated + // event and a container-originated one travel the identical path. + emitter.emitJson(payloadJson); } @JavascriptInterface diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventEmitter.java b/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventEmitter.java new file mode 100644 index 00000000..92229ca0 --- /dev/null +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventEmitter.java @@ -0,0 +1,124 @@ +package org.curiouslearning.container.core.subapp.emitter; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import org.curiouslearning.container.core.subapp.handler.AppEventPayloadHandler; +import org.curiouslearning.container.core.subapp.handler.AppEventWriteCallback; +import org.curiouslearning.container.core.subapp.handler.DefaultAppEventPayloadHandler; +import org.curiouslearning.container.core.subapp.handler.OneShotWriteCallback; +import org.curiouslearning.container.core.subapp.payload.AppEventPayload; +import org.curiouslearning.container.core.subapp.validation.AppEventPayloadValidator; +import org.curiouslearning.container.core.subapp.validation.ValidationResult; + +/** + * The single entry point for emitting an {@link AppEventPayload}, from JavaScript or from Java. + * + *

Before this existed the only way into {@link DefaultAppEventPayloadHandler} was the WebView + * bridge, so container-measured data had nowhere to go. Rather than give Java a second Firestore + * write path, both sources funnel through here: same validator, same handler instance, therefore the + * same metadata/attribution stamping, the same {@code FieldValue.increment} semantics and the same + * {@code synced_at} server timestamp. + * + *

Construct payloads with {@link AppEventPayloadBuilder}, which cannot produce an {@code options} + * value outside the validator's {@code add}/{@code replace} allowlist. + */ +public final class AppEventEmitter { + + private static final String TAG = "AppEventEmitter"; + + private final Gson gson = new Gson(); + private final AppEventPayloadValidator validator = new AppEventPayloadValidator(); + private final AppEventPayloadHandler handler; + + /** + * Resolves to the process-wide handler for {@code crUserId} — the same instance MainActivity + * warms on container open — so every writer shares one warmed Firestore cache. + */ + public static AppEventEmitter forUser(@NonNull String crUserId) { + return new AppEventEmitter(DefaultAppEventPayloadHandler.getInstance(crUserId)); + } + + public AppEventEmitter(@NonNull AppEventPayloadHandler handler) { + this.handler = handler; + } + + /** @see #emit(AppEventPayload, AppEventWriteCallback) */ + public boolean emit(AppEventPayload payload) { + return emit(payload, null); + } + + /** + * Validates {@code payload} and hands it to the handler. + * + * @return {@code true} when the payload was accepted for delivery — it passed validation and is + * now the handler's responsibility. {@code false} means it was rejected and dropped; + * nothing will be written. A {@code true} return says nothing about whether the write + * reached Firestore: use {@code callback} for that. + */ + public boolean emit(AppEventPayload payload, @Nullable AppEventWriteCallback callback) { + + // Wrapped here and handed down, so the catch-all below cannot report a second terminal + // result on top of one the handler already delivered. + OneShotWriteCallback once = OneShotWriteCallback.wrap(callback); + + try { + ValidationResult result = validator.validate(payload); + + if (!result.isValid) { + Log.e(TAG, "Payload rejected: " + result.errorMessage); + once.onFailed(new IllegalArgumentException(result.errorMessage)); + return false; + } + + handler.handle(payload, once); + return true; + + } catch (Exception e) { + Log.e(TAG, "Unexpected error handling payload", e); + once.onFailed(e); + return false; + } + } + + /** @see #emitJson(String, AppEventWriteCallback) */ + public boolean emitJson(String payloadJson) { + return emitJson(payloadJson, null); + } + + /** + * Parses a JSON payload — the form the WebView bridge receives — then emits it. Same return + * semantics as {@link #emit(AppEventPayload, AppEventWriteCallback)}, with malformed or empty + * JSON counting as a rejection. + */ + public boolean emitJson(String payloadJson, @Nullable AppEventWriteCallback callback) { + + OneShotWriteCallback once = OneShotWriteCallback.wrap(callback); + + try { + if (payloadJson == null || payloadJson.trim().isEmpty()) { + Log.e(TAG, "Rejected payload: empty JSON"); + once.onFailed(new IllegalArgumentException("Rejected payload: empty JSON")); + return false; + } + + AppEventPayload payload = gson.fromJson(payloadJson, AppEventPayload.class); + + return emit(payload, once); + + } catch (JsonSyntaxException e) { + Log.e(TAG, "Invalid JSON payload", e); + once.onFailed(e); + return false; + } catch (Exception e) { + Log.e(TAG, "Unexpected error handling payload", e); + once.onFailed(e); + return false; + } + } +} diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventPayloadBuilder.java b/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventPayloadBuilder.java new file mode 100644 index 00000000..3e745f1e --- /dev/null +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/emitter/AppEventPayloadBuilder.java @@ -0,0 +1,104 @@ +package org.curiouslearning.container.core.subapp.emitter; + +import androidx.annotation.NonNull; + +import org.curiouslearning.container.core.subapp.payload.AppEventPayload; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Fluent construction of an {@link AppEventPayload} for Java callers. + * + *

{@link AppEventPayload} stays a plain Gson DTO with public fields, which is what the JS bridge + * needs; this builder is what Java uses so a hand-filled payload cannot silently omit + * {@code timestamp} or put a value in {@code options} that the validator will later reject. + * {@link #add} and {@link #replace} are the only ways to populate {@code data}, so the two maps can + * never disagree. + * + *

{@code
+ * AppEventPayload payload = new AppEventPayloadBuilder()
+ *         .crUserId(pseudoId)
+ *         .appId(appId)
+ *         .collection("summary_data")
+ *         .schemaVersion("v1")
+ *         .add("cr_duration_seconds", cappedSeconds)
+ *         .add("cr_duration_raw_seconds", rawSeconds)
+ *         .build();
+ * }
+ */ +public final class AppEventPayloadBuilder { + + private static final String OPTION_ADD = "add"; + private static final String OPTION_REPLACE = "replace"; + + private final Map data = new HashMap<>(); + private final Map options = new HashMap<>(); + + private String crUserId; + private String appId; + private String collection; + private String schemaVersion; + private String timestamp; + + public AppEventPayloadBuilder crUserId(String crUserId) { + this.crUserId = crUserId; + return this; + } + + public AppEventPayloadBuilder appId(String appId) { + this.appId = appId; + return this; + } + + public AppEventPayloadBuilder collection(String collection) { + this.collection = collection; + return this; + } + + public AppEventPayloadBuilder schemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + return this; + } + + /** Defaults to {@code Instant.now()} at {@link #build()} time; set this only to override it. */ + public AppEventPayloadBuilder timestamp(String timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Accumulating field: written as an atomic {@code FieldValue.increment}, so the value passed + * here is a delta, not a running total. + */ + public AppEventPayloadBuilder add(@NonNull String field, @NonNull Number delta) { + data.put(field, delta); + options.put(field, OPTION_ADD); + return this; + } + + /** Overwriting field: the stored value becomes exactly {@code value}. */ + public AppEventPayloadBuilder replace(@NonNull String field, Object value) { + data.put(field, value); + options.put(field, OPTION_REPLACE); + return this; + } + + public AppEventPayload build() { + + AppEventPayload payload = new AppEventPayload(); + + payload.cr_user_id = crUserId; + payload.app_id = appId; + payload.collection = collection; + payload.schema_version = schemaVersion; + payload.timestamp = (timestamp != null && !timestamp.trim().isEmpty()) + ? timestamp + : Instant.now().toString(); + payload.data = new HashMap<>(data); + payload.options = new HashMap<>(options); + + return payload; + } +} diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventPayloadHandler.java b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventPayloadHandler.java index 754d818c..d9c13957 100644 --- a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventPayloadHandler.java +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventPayloadHandler.java @@ -3,5 +3,19 @@ import org.curiouslearning.container.core.subapp.payload.AppEventPayload; public interface AppEventPayloadHandler { - void handle(AppEventPayload payload); + + /** + * Fire-and-forget form, used by the JS bridge where nothing on the caller side needs to know + * when the write lands. + */ + default void handle(AppEventPayload payload) { + handle(payload, null); + } + + /** + * @param callback optional result signal; see {@link AppEventWriteCallback} for the contract and + * for why {@code onQueued} — not {@code onWritten} — is the point at which a + * caller may discard its own copy of the data. May be {@code null}. + */ + void handle(AppEventPayload payload, AppEventWriteCallback callback); } diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventWriteCallback.java b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventWriteCallback.java new file mode 100644 index 00000000..f79e3e64 --- /dev/null +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/AppEventWriteCallback.java @@ -0,0 +1,51 @@ +package org.curiouslearning.container.core.subapp.handler; + +/** + * Optional per-emit result signal for {@link AppEventPayloadHandler#handle}. + * + *

Two distinct success signals, because they mean different things on a device that is offline + * most of the time: + * + *

+ * + *

Timing: {@code onQueued()} fires synchronously for {@code user_sessions_data} (a direct + * add), but asynchronously for {@code summary_data}, which must first resolve its upsert query. + * Callers must act inside the callback rather than assume it ran by the time the emit call returns. + * + *

Contract: {@code onQueued()} at most once, and always before the terminal call. Exactly + * one terminal call — {@link #onWritten(String)} or {@link #onFailed(Exception)} — per emit. + * + *

All methods default to no-ops so callers implement only what they need. + */ +public interface AppEventWriteCallback { + + /** + * The write was issued to the Firestore SDK and is durable locally. Safe point to release any + * caller-side copy of the data. + */ + default void onQueued() { + } + + /** + * The server acknowledged the write. + * + * @param docId id of the document written, or {@code null} when it is not known at this call + * site (an update to an existing doc reports its id; a create reports the new one). + */ + default void onWritten(String docId) { + } + + /** + * The payload was rejected before any write (validation, unsupported collection, bad data + * shape), or the write itself failed server-side. + */ + default void onFailed(Exception e) { + } +} diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java index a952a72c..a049ac1b 100644 --- a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/DefaultAppEventPayloadHandler.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; +import com.google.android.gms.tasks.Task; import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.DocumentSnapshot; import com.google.firebase.firestore.FieldValue; @@ -75,7 +76,7 @@ private void prefetchSummaryDocs() { } @Override - public void handle(AppEventPayload payload) { + public void handle(AppEventPayload payload, AppEventWriteCallback callback) { Log.d( TAG, @@ -83,10 +84,10 @@ public void handle(AppEventPayload payload) { " collection=" + payload.collection ); - storePayload(payload); + storePayload(payload, OneShotWriteCallback.wrap(callback)); } - private void storePayload(@NonNull AppEventPayload payload) { + private void storePayload(@NonNull AppEventPayload payload, @NonNull OneShotWriteCallback callback) { FirebaseFirestore db = FirebaseFirestore.getInstance(); String rawCollection = payload.collection; @@ -98,6 +99,8 @@ private void storePayload(@NonNull AppEventPayload payload) { ) { Log.e(TAG, "Invalid payload — missing or blank required fields"); + callback.onFailed(new IllegalArgumentException( + "Invalid payload — missing or blank required fields")); return; } @@ -139,21 +142,20 @@ private void storePayload(@NonNull AppEventPayload payload) { case COLLECTION_USER_SESSION: Log.d(TAG, "Handling user_sessions_data payload"); - storeUserSessionPayload(db, payload); + storeUserSessionPayload(db, payload, callback); break; case COLLECTION_SUMMARY: Log.d(TAG, "Handling summary_data payload"); - storeSummaryPayload(db, payload); + storeSummaryPayload(db, payload, callback); break; default: - Log.e( - TAG, - "Unsupported collection: raw='" + rawCollection + - "' normalized='" + normalizedCollection + - "' length=" + normalizedCollection.length() - ); + String unsupported = "Unsupported collection: raw='" + rawCollection + + "' normalized='" + normalizedCollection + + "' length=" + normalizedCollection.length(); + Log.e(TAG, unsupported); + callback.onFailed(new IllegalArgumentException(unsupported)); return; } } @@ -182,12 +184,15 @@ private String normalizeCollection(String collection) { */ private void storeUserSessionPayload( FirebaseFirestore db, - AppEventPayload payload + AppEventPayload payload, + @NonNull OneShotWriteCallback callback ) { if (!(payload.data instanceof Map)) { - Log.e(TAG, "Invalid payload.data type. Expected Map but got: " - + (payload.data == null ? "null" : payload.data.getClass())); + String message = "Invalid payload.data type. Expected Map but got: " + + (payload.data == null ? "null" : payload.data.getClass()); + Log.e(TAG, message); + callback.onFailed(new IllegalArgumentException(message)); return; } @@ -207,12 +212,23 @@ private void storeUserSessionPayload( record.put("data", data); - db.collection(payload.collection) - .add(record) - .addOnSuccessListener(ref -> - Log.d(TAG, "User session saved docId=" + ref.getId())) - .addOnFailureListener(e -> - Log.e(TAG, "Failed to save user session payload", e)); + Task write = db.collection(payload.collection).add(record); + + // Signalled before the listeners are attached, so "queued precedes the terminal callback" + // holds even when the write acks immediately. The write is already in Firestore's local + // persistence queue at this point and survives process death, so the caller may release its + // own copy; the listeners below only fire on server ack, which offline may be much later or + // never. + callback.onQueued(); + + write.addOnSuccessListener(ref -> { + Log.d(TAG, "User session saved docId=" + ref.getId()); + callback.onWritten(ref.getId()); + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Failed to save user session payload", e); + callback.onFailed(e); + }); } private String resolveContextString(AppContextKey key, String fallback) { @@ -236,12 +252,15 @@ private String resolveContextString(AppContextKey key, String fallback) { */ private void storeSummaryPayload( FirebaseFirestore db, - AppEventPayload payload + AppEventPayload payload, + @NonNull OneShotWriteCallback callback ) { if (!(payload.data instanceof Map)) { - Log.e(TAG, "Invalid payload.data type. Expected Map but got: " - + (payload.data == null ? "null" : payload.data.getClass())); + String message = "Invalid payload.data type. Expected Map but got: " + + (payload.data == null ? "null" : payload.data.getClass()); + Log.e(TAG, message); + callback.onFailed(new IllegalArgumentException(message)); return; } @@ -273,10 +292,10 @@ private void storeSummaryPayload( .limit(1) .get() .addOnSuccessListener(querySnapshot -> - onSummaryQueryResult(db, payload, record, firstDocId(querySnapshot))) + onSummaryQueryResult(db, payload, record, firstDocId(querySnapshot), callback)) .addOnFailureListener(e -> { Log.w(TAG, "Query failed — creating new summary record", e); - createNewSummaryDoc(db, payload, record); + createNewSummaryDoc(db, payload, record, callback); }); } else { // Language unknown: Firestore has no "field does not exist" filter, so match @@ -295,11 +314,11 @@ private void storeSummaryPayload( break; } } - onSummaryQueryResult(db, payload, record, existingDocId); + onSummaryQueryResult(db, payload, record, existingDocId, callback); }) .addOnFailureListener(e -> { Log.w(TAG, "Query failed — creating new summary record", e); - createNewSummaryDoc(db, payload, record); + createNewSummaryDoc(db, payload, record, callback); }); } } @@ -312,7 +331,8 @@ private void onSummaryQueryResult( FirebaseFirestore db, AppEventPayload payload, Map record, - String existingDocId + String existingDocId, + @NonNull OneShotWriteCallback callback ) { if (existingDocId != null) { @@ -320,22 +340,30 @@ private void onSummaryQueryResult( DocumentReference existingRef = db.collection(payload.collection).document(existingDocId); - existingRef.set(record, SetOptions.merge()) - .addOnSuccessListener(aVoid -> - Log.d(TAG, "Updated summary payload with id: " + existingDocId)) - .addOnFailureListener(e -> - Log.e(TAG, "Failed to update summary payload", e)); + Task write = existingRef.set(record, SetOptions.merge()); + + callback.onQueued(); + + write.addOnSuccessListener(aVoid -> { + Log.d(TAG, "Updated summary payload with id: " + existingDocId); + callback.onWritten(existingDocId); + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Failed to update summary payload", e); + callback.onFailed(e); + }); } else { Log.d(TAG, "No existing summary record — creating new"); - createNewSummaryDoc(db, payload, record); + createNewSummaryDoc(db, payload, record, callback); } } private void createNewSummaryDoc( FirebaseFirestore db, AppEventPayload payload, - Map record + Map record, + @NonNull OneShotWriteCallback callback ) { String now = Instant.now().toString(); @@ -348,12 +376,18 @@ private void createNewSummaryDoc( record.put("updated_at", now); record.put("schema_version", payload.schema_version != null ? payload.schema_version : "unknown"); - db.collection(payload.collection) - .add(record) - .addOnSuccessListener(ref -> - Log.d(TAG, "Created new summary payload docId=" + ref.getId())) - .addOnFailureListener(e -> - Log.e(TAG, "Failed to create summary payload", e)); + Task write = db.collection(payload.collection).add(record); + + callback.onQueued(); + + write.addOnSuccessListener(ref -> { + Log.d(TAG, "Created new summary payload docId=" + ref.getId()); + callback.onWritten(ref.getId()); + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Failed to create summary payload", e); + callback.onFailed(e); + }); } /** diff --git a/app/src/main/java/org/curiouslearning/container/core/subapp/handler/OneShotWriteCallback.java b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/OneShotWriteCallback.java new file mode 100644 index 00000000..9f79724f --- /dev/null +++ b/app/src/main/java/org/curiouslearning/container/core/subapp/handler/OneShotWriteCallback.java @@ -0,0 +1,73 @@ +package org.curiouslearning.container.core.subapp.handler; + +import android.util.Log; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Wraps a caller-supplied {@link AppEventWriteCallback} so an emit path can invoke it freely without + * null checks, and so the "at most one {@code onQueued}, exactly one terminal call" contract holds + * even on the paths that fan out (query failure falling back to a create, a listener firing after an + * early return, an emitter's catch-all running after the handler already reported). + * + *

A null delegate is absorbed into a no-op, which is what makes {@code handle(payload)} — the + * fire-and-forget form used by the JS bridge — free of callback plumbing at the call site. + * + *

{@link #wrap} is idempotent, so the emitter can wrap once and the handler can wrap again + * without either losing the shared once-only state. + */ +public final class OneShotWriteCallback implements AppEventWriteCallback { + + private static final String TAG = "AppEventHandler"; + + private final AppEventWriteCallback delegate; + private final AtomicBoolean queued = new AtomicBoolean(false); + private final AtomicBoolean finished = new AtomicBoolean(false); + + private OneShotWriteCallback(AppEventWriteCallback delegate) { + this.delegate = delegate; + } + + public static OneShotWriteCallback wrap(AppEventWriteCallback delegate) { + if (delegate instanceof OneShotWriteCallback) { + return (OneShotWriteCallback) delegate; + } + return new OneShotWriteCallback(delegate); + } + + @Override + public void onQueued() { + if (delegate == null || !queued.compareAndSet(false, true)) { + return; + } + try { + delegate.onQueued(); + } catch (Exception e) { + Log.e(TAG, "Write callback threw in onQueued", e); + } + } + + @Override + public void onWritten(String docId) { + if (delegate == null || !finished.compareAndSet(false, true)) { + return; + } + try { + delegate.onWritten(docId); + } catch (Exception e) { + Log.e(TAG, "Write callback threw in onWritten", e); + } + } + + @Override + public void onFailed(Exception cause) { + if (delegate == null || !finished.compareAndSet(false, true)) { + return; + } + try { + delegate.onFailed(cause); + } catch (Exception e) { + Log.e(TAG, "Write callback threw in onFailed", e); + } + } +}