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
48 changes: 9 additions & 39 deletions app/src/main/java/org/curiouslearning/container/WebApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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.
*
* <pre>{@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();
* }</pre>
*/
public final class AppEventPayloadBuilder {

private static final String OPTION_ADD = "add";
private static final String OPTION_REPLACE = "replace";

private final Map<String, Object> data = new HashMap<>();
private final Map<String, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.curiouslearning.container.core.subapp.handler;

/**
* Optional per-emit result signal for {@link AppEventPayloadHandler#handle}.
*
* <p>Two distinct success signals, because they mean different things on a device that is offline
* most of the time:
*
* <ul>
* <li>{@link #onQueued()} — the write has been handed to the Firestore SDK and is durable in its
* local persistence queue. This is the point at which a caller holding the only other copy of
* the measurement (e.g. a coalesced usage buffer) can safely discard it.</li>
* <li>{@link #onWritten(String)} — the server acknowledged the write. Offline this fires late, or
* never. Do not gate local cleanup on it; a caller that did would replay its buffer on every
* launch and double-count.</li>
* </ul>
*
* <p><b>Timing:</b> {@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.
*
* <p><b>Contract:</b> {@code onQueued()} at most once, and always before the terminal call. Exactly
* one terminal call — {@link #onWritten(String)} or {@link #onFailed(Exception)} — per emit.
*
* <p>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) {
}
}
Loading