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 MapTwo 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 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);
+ }
+ }
+}