diff --git a/AGENTS.md b/AGENTS.md index e07a77b..0eec6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,10 @@ a run stops as safety-critical (see SAFETY.md). 1. Never weaken, skip or delete a check to make a change pass. Fix the code or explain. 2. Never bypass the stop/vent confirmation, the ceiling clamp, or the START gate. 3. Saved-data changes must keep old files loading; add a `Migrate` case. -4. No network code in release builds; no data leaves the phone unless the user opts in. +4. GrowthTrack is the optional release network integration: public-client PKCE, secure tokens, + browser approval of an account connection enables automatic transfer of every new real + finished session. Disconnected and unmarked earlier history never upload; pending work + stays bound to its original grant. No per-session opt-in is required. Diagnostic log upload remains debug-only. 5. UI: Java-built views (no XML layouts), 48 dp minimum touch targets, colours and sizes from `Look`, dialogs through `Ui.dialog()` + `Ui.dress()`. 6. New logic that can be pure goes in `core/` with a JUnit test. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b7f125..6faf50d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,8 +9,11 @@ so that most of the logic can be understood, changed and tested without a phone. app/ Android shell — screens, the foreground run service, Bluetooth (PumpLink), notifications, widget, file providers. Depends on core. app/src/debug is in debug builds only: the diagnostic console (MainActivity, - its own Bluetooth connection and raw frames) and the INTERNET permission its - log upload needs. The release APK carries neither. + its own Bluetooth connection and raw frames) and cleartext diagnostic log upload. + The release APK carries no diagnostic console. Optional + GrowthTrack has INTERNET/ACCESS_NETWORK_STATE in main. Browser approval enables + automatic same-account session sync, with a separate Activity/task, encrypted + no-backup state and persisted network-constrained retry jobs. core/ Plain Java — no Android imports, enforced by ArchitectureTest. Training plan, session engine, data model and persistence, share codes, progress/analytics, and the ZD21 pump protocol (Proto) with its simulator; @@ -25,6 +28,7 @@ is being removed — see the roadmap). | You want to… | Look in | |---|---| +| Change the optional GrowthTrack connection | `core/./GrowthTrack*.java`, `app/./GrowthTrack*.java`; [developer handoff](docs/growthtrack-developer-handoff.md) | | Change a screen | `app/src/main/java/org/openpump/*Screen.java`, `SessionActivity.java` | | Change the training plan | `core/…/Plan.java`, `RxBuild.java`, `Mint.java`, `Deload.java` | | Change how a run is recorded or saved | `core/…/AsRun.java`, `RunEdit.java`, `Session.java` | diff --git a/README.md b/README.md index d1fce38..2e001d0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

Licence: AGPL-3.0 Android 7.0 or newer - No account, no server, no analytics + Optional GrowthTrack connection, automatic session sync Latest release: 0.10.0

@@ -36,7 +36,9 @@ The goal is bigger than any one feature: the best app there is to pump, track yo - **Works with** the Epic Hydro PE Pump over Bluetooth. It's the one pump checked on real hardware so far. Others can be added by the people who own them. - **No pump?** Practice mode runs everything against a simulated pump, and marks those runs as simulated. -- **Your data** stays on your phone. No account, no server, no analytics. +- **Your data** stays on your phone until you export/share it or connect your own GrowthTrack account. Connection enables automatic sync of new real finished sessions; disconnected and preconnection history does not send. Measurements, photos and notes are excluded from this optional connection. + + This feature branch adds the GrowthTrack connection and read-only routine previews. Android build/device validation is still required before release; see the [developer handoff](docs/growthtrack-developer-handoff.md) and [verification record](docs/growthtrack-verification.md). - **You need** Android 7.0 or newer. - **It's free** and open source, under AGPL-3.0. diff --git a/SAFETY.md b/SAFETY.md index 7a25e71..b33816f 100644 --- a/SAFETY.md +++ b/SAFETY.md @@ -53,7 +53,11 @@ own risk — the full terms, including the warranty and liability disclaimer, ar or leaving a screen never orphans a pump under pressure. 6. **Practice mode is never mistaken for a pump.** Simulated runs are labelled everywhere and filed as simulated. -7. **Nothing is sent on the user's behalf.** No network traffic in release builds. +7. **Connection is optional and authorizes automatic sync.** GrowthTrack linking uses + the system browser; every new real finished session transfers to that grant. Disconnected + and preconnection history never sends. No measurements, photos or notes are uploaded. + Account-bound encrypted pending work retries automatically. Data transfer owns no pump connection and + cannot start hardware. The diagnostic log console remains debug-only. These are enforced by code review, by `WiringCheck` (static checks over the source), by the self-test, and — as pump drivers arrive — by a conformance kit every driver must pass. diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index c4a1f5a..62530b7 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -7,8 +7,9 @@ builds developers make themselves. SessionActivity reaches it by class name, and only when BuildConfig.DEBUG says it is here. - INTERNET and cleartext traffic are the console's log upload to a developer's own - computer. Release builds never hold the INTERNET permission. --> + Cleartext traffic is for the console's log upload to a developer's own computer. + INTERNET is also declared in main for optional GrowthTrack account connection and automatic session sync. + Release builds forbid cleartext and carry no diagnostic console. --> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0dbe550..db16f21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + @@ -161,7 +165,8 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:theme="@android:style/Theme.DeviceDefault.NoActionBar" - android:allowBackup="false"> + android:allowBackup="false" + android:usesCleartextTraffic="false"> + + + + + + + + + + + + diff --git a/app/src/main/java/org/openpump/GrowthTrackActivity.java b/app/src/main/java/org/openpump/GrowthTrackActivity.java new file mode 100644 index 0000000..c47acce --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackActivity.java @@ -0,0 +1,335 @@ +package org.openpump; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.KeyguardManager; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONObject; + +/** Optional account connection and automatic-sync status. This activity never owns a pump link. */ +public final class GrowthTrackActivity extends Activity { + private static final int UNLOCK = 721; + private GrowthTrackManager manager; + private Model model; + private LinearLayout body; + private GrowthTrackClient.Snapshot snapshot; + private final Map receipts = new LinkedHashMap(); + private String onlyId, page = "home", message = ""; + private boolean busy, unlocked, askingUnlock; + private JSONArray library; + private JSONObject routine; + + /** Main-thread observation only: refusing this handoff never commands or stops hardware. */ + static boolean refuseHandOffUnderHold(Activity from) { + SessionActivity owner = SessionActivity.liveForDataHandoff(); + if ((owner != null && owner.stillUnsafe()) || RunService.isRunning()) { + if (owner != null) owner.toast("Vent the pump before opening GrowthTrack or its browser"); + return true; + } + return false; + } + static void open(SessionActivity owner, Model.Sess session) { + if (refuseHandOffUnderHold(owner)) return; + Intent intent = new Intent(owner, GrowthTrackActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (session != null) intent.putExtra("session_id", session.id); + owner.startActivity(intent); + } + static final class OpenTap implements View.OnClickListener { + private final SessionActivity owner; private final Model.Sess session; + OpenTap(SessionActivity owner, Model.Sess session) { this.owner = owner; this.session = session; } + @Override public void onClick(View v) { open(owner, session); } + } + static final class InformationTap implements View.OnClickListener { + final SessionActivity owner; + InformationTap(SessionActivity owner) { this.owner = owner; } + @Override public void onClick(View view) { + if (refuseHandOffUnderHold(owner)) return; + owner.startActivity(new Intent(owner, GrowthTrackActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("information", true)); + } + } + @Override protected void onCreate(Bundle b) { + super.onCreate(b); manager = GrowthTrackManager.get(this); model = Store.load(this, false); + SessionActivity.applySecureFlag(getWindow(), model); + onlyId = b == null ? getIntent().getStringExtra("session_id") : null; + if (getIntent().getBooleanExtra("information", false)) page = "about"; + message = getIntent().getStringExtra("connection_message"); if (message == null) message = ""; + buildRoot(); + } + @Override protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); setIntent(intent); model = Store.load(this, false); + SessionActivity.applySecureFlag(getWindow(), model); + page = "home"; onlyId = intent.getStringExtra("session_id"); + if (intent.getBooleanExtra("information", false)) page = "about"; + message = intent.getStringExtra("connection_message"); if (message == null) message = ""; + refresh(); + } + private void buildRoot() { + ScrollView scroll = new ScrollView(this); scroll.setFillViewport(true); scroll.setBackgroundColor(Ui.BG); + body = Ui.col(this); int padding = Ui.dp(this, Look.S5); body.setPadding(padding, padding, padding, padding); + scroll.addView(body, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); setContentView(scroll); + } + @Override protected void onResume() { + super.onResume(); model = Store.load(this, false); SessionActivity.applySecureFlag(getWindow(), model); + if (needsUnlock() && !unlocked) { lockScreen(); return; } refresh(); + } + private boolean needsUnlock() { return model.appLockOn && (model.appLockWholeApp || model.appLockSettings || model.appLockSessions); } + private void lockScreen() { + body.removeAllViews(); Ui.head(this, body, "GrowthTrack"); Ui.note(this, body, "Unlock to manage your connection and view sync status."); + button("Unlock", new Tap(Action.UNLOCK)); button("Back", new Tap(Action.FINISH)); + } + private void askUnlock() { + if (refuseHandOffUnderHold(this)) return; + KeyguardManager keyguard = (KeyguardManager) getSystemService(KEYGUARD_SERVICE); + Intent intent = keyguard == null ? null : keyguard.createConfirmDeviceCredentialIntent("Unlock", "Review your connected app and sessions"); + if (intent == null) { Ui.note(this, body, "Set a device PIN, pattern or password before opening this locked area."); return; } + askingUnlock = true; startActivityForResult(intent, UNLOCK); + } + @Override protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + if (request == UNLOCK) { askingUnlock = false; unlocked = result == RESULT_OK; if (unlocked) refresh(); else lockScreen(); } + } + @Override protected void onStop() { + super.onStop(); + if (RunService.isRunning()) { library = null; routine = null; } // Private previews yield; automatic sync never owns the pump. + if (!askingUnlock) { unlocked = false; if (needsUnlock()) body.removeAllViews(); } + library = null; routine = null; + if ("library".equals(page) || "routine".equals(page)) { page = "home"; body.removeAllViews(); } + } + private void refresh() { + if (needsUnlock() && !unlocked) return; + List ids = new ArrayList(); for (Model.Sess s : model.sessLog.all) ids.add(s.id); + manager.run(new ReadState(ids), new Completed(null, true, false)); + } + private static final class StateView { + final GrowthTrackClient.Snapshot snapshot; final Map receipts; + StateView(GrowthTrackClient.Snapshot snapshot, Map receipts) { this.snapshot = snapshot; this.receipts = receipts; } + } + private static final class ReadState implements GrowthTrackManager.Work { + final List ids; ReadState(List ids) { this.ids = ids; } + @Override public Object run(GrowthTrackClient client) { + Map statuses = new LinkedHashMap(); + for (String id : ids) try { statuses.put(id, client.receipt(GrowthTrackProtocol.stableId(id))); } catch (IllegalArgumentException ignored) { } + return new StateView(client.snapshot(), statuses); + } + } + private void work(GrowthTrackManager.Work task, String next) { busy = true; render(); manager.run(task, new Completed(next, false, false)); } + private final class Completed implements GrowthTrackManager.Listener { + final String next; final boolean readState, reset; + Completed(String next, boolean readState, boolean reset) { this.next = next; this.readState = readState; this.reset = reset; } + @Override public void done(Object result, String error) { runOnUiThread(new Delivered(result, error, next, readState, reset)); } + } + private final class Delivered implements Runnable { + final Object result; final String error, next; final boolean readState, reset; + Delivered(Object result, String error, String next, boolean readState, boolean reset) { this.result = result; this.error = error; this.next = next; this.readState = readState; this.reset = reset; } + @Override public void run() { + if (isFinishing() || isDestroyed()) return; + if (readState) { + if (result instanceof StateView) { StateView state = (StateView) result; snapshot = state.snapshot; receipts.clear(); receipts.putAll(state.receipts); } + if (error != null) message = error; if (!needsUnlock() || unlocked) render(); return; + } + busy = false; + if (error != null) message = error; else { message = reset ? "Disconnected on this device." : ""; if (next != null) page = next; } + if (reset) { snapshot = null; library = null; routine = null; } + if (result instanceof String && error == null) { + if (hasWindowFocus() && (!needsUnlock() || unlocked)) openBrowser((String) result); + else message = "Connection prepared. Return and tap Connect to open your browser."; + } + if (result instanceof JSONObject && error == null) { + JSONObject json = (JSONObject) result; + // No private response cache survives backgrounding or a locked screen. + if (hasWindowFocus() && (!needsUnlock() || unlocked)) { + if (json.optJSONArray("routines") != null) library = json.optJSONArray("routines"); else routine = json.optJSONObject("routine"); + } else page = "home"; + } + refresh(); + } + } + private enum Operation { BEGIN, CANCEL_AUTH, ROUTINES } + private static final class Task implements GrowthTrackManager.Work { + final Operation operation; final boolean read; final String id; final List payloads; + Task(Operation operation) { this(operation, false, null, null); } + Task(Operation operation, boolean read, String id, List payloads) { this.operation = operation; this.read = read; this.id = id; this.payloads = payloads; } + @Override public Object run(GrowthTrackClient client) throws Exception { + switch (operation) { + case BEGIN: return client.begin(read); + case CANCEL_AUTH: client.cancelAuthorization(); break; + case ROUTINES: return client.routines(id); + default: throw new IllegalStateException(); + } + return null; + } + } + private void render() { + if (needsUnlock() && !unlocked) { lockScreen(); return; } + body.removeAllViews(); Ui.header(this, body, "GrowthTrack", new Tap(Action.BACK)); + if (!message.isEmpty()) Ui.noteInfo(this, body, "Connection update", "GrowthTrack", message); + if (busy) Ui.note(this, body, "Working."); + if ("status".equals(page)) statusScreen(); + else if ("library".equals(page)) libraryScreen(); else if ("routine".equals(page)) routineScreen(); + else if ("about".equals(page)) informationScreen(); else homeScreen(); + } + private void homeScreen() { + Ui.head(this, body, "Your GrowthTrack account"); + button("About GrowthTrack and this connection", new Tap(Action.ABOUT)); + if (snapshot == null) Ui.note(this, body, "Reading connection state."); + else Ui.noteInfo(this, body, snapshot.connected ? "Connected" : snapshot.pending ? "Waiting for browser approval" : "Not connected", "Connection status", snapshot.message); + Ui.noteInfo(this, body, "Connected: new sessions sync automatically.", "Connecting your account", + "Connection enables automatic transfer of every new real finished session to the account you approve in GrowthTrack. Disconnected sessions and earlier history stay on your phone. There is no per-session selection or Send step. Unsupported recordings show an explicit sync error."); + CheckBox read = check("Also allow reading my GrowthTrack routines", snapshot != null && snapshot.routines); + button(snapshot != null && snapshot.connected ? "Reconnect / change account" : "Connect to GrowthTrack", new ConnectTap(read)); + if (snapshot != null && snapshot.pending) button("Cancel connection request", new Tap(Action.CANCEL_AUTH)); + if (snapshot != null && snapshot.connected) { + button("Session sync status", new Tap(Action.STATUS)); + if (snapshot.queued > 0) { + Ui.note(this, body, snapshot.queued + " sessions are pending. OpenPump retries them automatically when the connection is available."); + long wait = Math.max(0, snapshot.retryAt - System.currentTimeMillis()); + Button retry = button(snapshot.paused ? "Reconnect to restore sync" : wait > 0 ? "Retry in " + ((wait + 999) / 1000) + " seconds" : "Retry sync", new Tap(Action.RETRY)); + retry.setEnabled(!busy && wait == 0 && !snapshot.paused); + if (wait > 0) body.postDelayed(new RetryDisplay(), Math.min(wait + 100, 60_000)); + } + button("Preview GrowthTrack routines", new Tap(Action.LIBRARY)); + } + button("Manage access in GrowthTrack", new Tap(Action.MANAGE)); button("Disconnect on this device", new Tap(Action.DISCONNECT)); + } + private final class RetryDisplay implements Runnable { + @Override public void run() { if (!isFinishing() && !isDestroyed() && hasWindowFocus() && "home".equals(page)) refresh(); } + } + private final class ConnectTap implements View.OnClickListener { + final CheckBox read; ConnectTap(CheckBox read) { this.read = read; } + @Override public void onClick(View view) { + dress(Ui.dialog(GrowthTrackActivity.this).setTitle("Connect to GrowthTrack?") + .setMessage("Choose your own GrowthTrack account in the system browser. OpenPump can add training sessions" + (read.isChecked() ? " and read your routine library" : "") + ". Every new real finished session syncs automatically to this account. Earlier history is not uploaded. It cannot read measurements or session history. Reconnecting discards pending transfers for the previous approval.") + .setPositiveButton("Continue", new Decision(Action.CONNECT, read.isChecked(), null)).setNegativeButton("Cancel", null).show()); + } + } + private enum Action { UNLOCK, FINISH, BACK, CONNECT, CANCEL_AUTH, STATUS, RETRY, LIBRARY, MANAGE, DISCONNECT, RESET, LIBRARY_BACK, ABOUT, PROJECT, HOME } + private final class Tap implements View.OnClickListener { + final Action action; Tap(Action action) { this.action = action; } + @Override public void onClick(View view) { act(action, false, null); } + } + private final class Decision implements DialogInterface.OnClickListener { + final Action action; final boolean read; final List payloads; + Decision(Action action, boolean read, List payloads) { this.action = action; this.read = read; this.payloads = payloads; } + @Override public void onClick(DialogInterface dialog, int which) { act(action, read, payloads); } + } + private void act(Action action, boolean read, List payloads) { + if (busy && action != Action.BACK) return; + switch (action) { + case ABOUT: page = "about"; render(); break; + case PROJECT: openBrowser("https://pe-growth-track.com/"); break; + case HOME: page = "home"; render(); break; + case UNLOCK: askUnlock(); break; + case FINISH: finish(); break; + case BACK: onBackPressed(); break; + case CONNECT: work(new Task(Operation.BEGIN, read, null, null), "home"); break; + case CANCEL_AUTH: work(new Task(Operation.CANCEL_AUTH), "home"); break; + case STATUS: page = "status"; refresh(); break; + case RETRY: busy = true; render(); manager.startSync(new Completed("home", false, false)); break; + case LIBRARY: + if (snapshot == null || !snapshot.routines) { message = "Reconnect with the routine permission selected to preview your library."; render(); } + else work(new Task(Operation.ROUTINES), "library"); break; + case MANAGE: openBrowser(GrowthTrackProtocol.CONNECTED_APPS); break; + case DISCONNECT: + dress(Ui.dialog(this).setTitle("Disconnect GrowthTrack?") + .setMessage("Remove tokens and stop automatic sync and discard pending transfers on this device. Sessions already received remain in GrowthTrack. Also remove OpenPump from GrowthTrack Connected apps to revoke its server approval.") + .setPositiveButton("Disconnect", new Decision(Action.RESET, false, null)).setNegativeButton("Cancel", null).show()); break; + case RESET: busy = true; render(); manager.reset(new Completed("home", false, true)); break; + case LIBRARY_BACK: routine = null; page = "library"; render(); break; + default: throw new IllegalStateException(); + } + } + private void dress(AlertDialog dialog) { Ui.dress(this, dialog); SessionActivity.applySecureFlag(dialog.getWindow(), model); } + private void informationScreen() { + Ui.head(this, body, "GrowthTrack, by your choice"); + Ui.note(this, body, "GrowthTrack is a separate PE progress tracker for recording training sessions, routines and measurements in your own account."); + Ui.noteInfo(this, body, "Connect now or later in Settings.", "How to connect", + "Visit GrowthTrack to create or sign in to your account. Back here, choose Connect and approve OpenPump in your system browser. Check the account shown there. You can decline or cancel and keep using OpenPump."); + Ui.noteInfo(this, body, "Connect once; every new session syncs.", "What leaves your phone", + "Your connection authorizes automatic transfer of every real finished session recorded while connected. Sync includes IDs, original times, routine names, fixed automatic technique labels, set durations and recorded peak/net time. Photos, measurements and notes stay out. Earlier history is not uploaded. Offline sessions retry automatically; an unrepresentable recording shows why it cannot sync."); + Ui.noteInfo(this, body, "Routine previews are optional.", "Routine permission", + "With extra permission you can read your GrowthTrack routine library. These are previews only; they do not import executable pump routines or start the pump."); + Ui.noteInfo(this, body, "You control disconnection.", "Remove access", + "Disconnect clears local tokens and pending sends. Remove OpenPump in GrowthTrack Connected apps to revoke server approval. Records already sent remain in your GrowthTrack account."); + button("Visit GrowthTrack", new Tap(Action.PROJECT)); + button("Connection and session controls", new Tap(Action.HOME)); + button("Back to OpenPump", new Tap(Action.FINISH)); + } + private void statusScreen() { + Ui.head(this, body, "Session sync status"); + Ui.noteInfo(this, body, "New sessions sync automatically while connected.", "Session sync", "Every new real finished session syncs while connected. No sessions are selected individually. Earlier or disconnected history is not uploaded."); + int shown = 0; + for (Model.Sess session : model.sessLog.all) { + if (onlyId != null && !onlyId.equals(session.id)) continue; + shown++; + String status = receipts.getOrDefault(session.id, ""); + if (session.sim) status = "Not synced: simulated recording"; + else if (status.startsWith("Received")) { /* Keep confirmed receipts visible after access expires. */ } + else if (session.growthTrackConnectionId.isEmpty()) status = "Not synced: recorded before connection or while disconnected"; + else if (snapshot == null || !snapshot.connected || !session.growthTrackConnectionId.equals(snapshot.connectionId)) + status = "Previous or disconnected approval: current sync stopped; previously sent records may remain in that account"; + else if (status.isEmpty()) status = "Waiting for automatic sync"; + Ui.noteInfo(this, body, session.routineName == null ? "OpenPump session" : session.routineName, "Session sync", status); + } + if (shown == 0) Ui.note(this, body, "There are no recorded sessions here."); + Ui.noteInfo(this, body, "Technique labels are fixed.", "Automatic technique rules", + "Positive confirmed holds up to 6 seconds are Milking; above 6 through 30 are RIP; above 30 through 120 are Interval pumping; longer holds are Static pumping. Release and rest do not determine the label. The recorded Length track takes precedence. Unsupported timing or missing original timestamps are reported, never invented."); + button("Refresh status", new Tap(Action.STATUS)); + } + private void libraryScreen() { + Ui.head(this, body, "GrowthTrack routine library"); Ui.note(this, body, "Read-only previews. A recipe's intensity hint is not an OpenPump machine setting."); + if (library == null) Ui.note(this, body, "Refresh to read the library again."); + else for (int i = 0; i < library.length(); i++) { + JSONObject item = library.optJSONObject(i); if (item != null) button(item.optString("name", "Routine") + " / " + item.optString("access") + " / " + item.optInt("step_count") + " steps", new RoutineTap(item.optString("id"))); + } + button("Refresh library", new Tap(Action.LIBRARY)); + } + private final class RoutineTap implements View.OnClickListener { + final String id; RoutineTap(String id) { this.id = id; } + @Override public void onClick(View v) { work(new Task(Operation.ROUTINES, false, id, null), "routine"); } + } + private void routineScreen() { + if (routine == null) { Ui.note(this, body, "This preview is no longer available. Refresh the library."); return; } + Ui.head(this, body, routine.optString("name", "GrowthTrack routine")); Ui.note(this, body, "Revision " + routine.optString("revision") + " / " + routine.optString("access")); + JSONArray steps = routine.optJSONArray("steps"); + if (steps != null) for (int i = 0; i < steps.length(); i++) { + JSONObject step = steps.optJSONObject(i); if (step == null) continue; + Ui.fieldLabel(this, body, step.optInt("position", i + 1) + ". " + step.optString("exercise_name", "Exercise"), null); + Ui.note(this, body, step.optInt("sets", 1) + " sets / " + (step.isNull("work_seconds_per_set") ? "untimed / repetitions" : step.optInt("work_seconds_per_set") + " seconds per set")); + Ui.note(this, body, "Rest: " + step.optInt("rest_seconds_between_sets", 0) + " seconds between sets" + (i < steps.length() - 1 ? " and before the next step" : "; no trailing rest")); + if (!step.isNull("reps")) Ui.note(this, body, "Repetitions: " + step.optInt("reps")); + if (!step.isNull("intensity_hint")) Ui.noteInfo(this, body, "Author's intensity guidance", "Intensity hint", step.optString("intensity_hint")); + if (step.optBoolean("heat") || step.optBoolean("vibration")) Ui.note(this, body, "Author suggests " + (step.optBoolean("heat") ? "heat " : "") + (step.optBoolean("vibration") ? "vibration" : "")); + if ("own".equals(routine.optString("access")) && !step.isNull("notes")) Ui.noteInfo(this, body, "Your step notes", "Notes", step.optString("notes")); + } + Ui.noteInfo(this, body, "This preview cannot start an OpenPump routine.", "Device settings still need your review", + "Blocks are already expanded in this order. This device-neutral recipe does not supply the vacuum, release and cycle settings needed for a runnable OpenPump routine. Review and enter those settings in OpenPump's editor; this preview never starts hardware or silently substitutes values."); + button("Back to library", new Tap(Action.LIBRARY_BACK)); + } + private CheckBox check(String text, boolean checked) { + CheckBox box = new CheckBox(this); box.setText(text); box.setTextColor(Ui.TEXT); box.setTextSize(Look.SP_BODY); box.setMinHeight(Ui.dp(this, 48)); box.setChecked(checked); box.setEnabled(!busy); + body.addView(box, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return box; + } + private Button button(String text, View.OnClickListener listener) { Button b = Ui.flat(this, body, text); b.setMinimumHeight(Ui.dp(this, 48)); b.setEnabled(!busy); b.setOnClickListener(listener); return b; } + private void openBrowser(String url) { + if (refuseHandOffUnderHold(this)) return; + try { startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))); } + catch (android.content.ActivityNotFoundException e) { message = "No system browser is available. Enable a browser to connect."; manager.run(new Task(Operation.CANCEL_AUTH), new Completed(null, true, false)); render(); } + } + @Override public void onBackPressed() { + if (!"home".equals(page)) { page = "home"; render(); } else super.onBackPressed(); + } +} diff --git a/app/src/main/java/org/openpump/GrowthTrackCallbackActivity.java b/app/src/main/java/org/openpump/GrowthTrackCallbackActivity.java new file mode 100644 index 0000000..45fe19e --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackCallbackActivity.java @@ -0,0 +1,35 @@ +package org.openpump; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.widget.LinearLayout; + +/** Exported callback: no history and no credential-bearing URI forwarded to another activity. */ +public final class GrowthTrackCallbackActivity extends Activity { + @Override protected void onCreate(Bundle saved) { + super.onCreate(saved); SessionActivity.applySecureFlag(getWindow(), Store.load(this, false)); + LinearLayout body = Ui.col(this); body.setBackgroundColor(Ui.BG); body.setPadding(Ui.dp(this, 24), Ui.dp(this, 24), Ui.dp(this, 24), Ui.dp(this, 24)); + Ui.head(this, body, "Connecting to GrowthTrack."); setContentView(body); + String callback = getIntent().getDataString(); getIntent().setData(null); + if (!Intent.ACTION_VIEW.equals(getIntent().getAction()) || callback == null) { finish(); return; } + GrowthTrackManager.get(this).run(new Exchange(callback), new Completed()); + } + private static final class Exchange implements GrowthTrackManager.Work { + final String callback; Exchange(String callback) { this.callback = callback; } + @Override public Object run(GrowthTrackClient client) throws Exception { client.callback(callback); return null; } + } + private final class Completed implements GrowthTrackManager.Listener { + @Override public void done(Object result, String error) { runOnUiThread(new Delivered(error)); } + } + private final class Delivered implements Runnable { + final String error; Delivered(String error) { this.error = error; } + @Override public void run() { + if (isFinishing() || isDestroyed()) return; + if (GrowthTrackActivity.refuseHandOffUnderHold(GrowthTrackCallbackActivity.this)) return; + Intent open = new Intent(GrowthTrackCallbackActivity.this, GrowthTrackActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + open.putExtra("connection_message", error == null ? "GrowthTrack connection response received. No sessions were sent." : error); + startActivity(open); finish(); + } + } +} diff --git a/app/src/main/java/org/openpump/GrowthTrackHttp.java b/app/src/main/java/org/openpump/GrowthTrackHttp.java new file mode 100644 index 0000000..f6f7fa1 --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackHttp.java @@ -0,0 +1,58 @@ +package org.openpump; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; + +/** Fixed-origin HTTPS only, with no redirect forwarding, cookies, caches or payload logging. */ +final class GrowthTrackHttp implements GrowthTrackClient.Transport { + private volatile HttpsURLConnection active; + private android.net.Network network; + synchronized void useNetwork(android.net.Network value) { network = value; } + private long generation, operationGeneration; + synchronized long ticket() { return generation; } + synchronized void enter(long ticket) throws IOException { + if (ticket != generation) throw new IOException("GrowthTrack operation was cancelled before it started"); + operationGeneration = ticket; + } + synchronized void cancel() { generation++; HttpsURLConnection connection = active; if (connection != null) connection.disconnect(); } + @Override public GrowthTrackClient.Response request(String method, String path, Map headers, String body) throws IOException { + if (!(path.equals("/oauth-token") || path.equals("/partner-ingest") || path.matches("/partner-routines(?:\\?id=[0-9a-fA-F-]{36})?"))) + throw new IOException("Unsupported GrowthTrack endpoint"); + if (!("GET".equals(method) || "POST".equals(method))) throw new IOException("Unsupported GrowthTrack operation"); + HttpsURLConnection connection; + synchronized (this) { + // A queued Send may enter approve() after a Back/Cancel. Its old operation + // ticket still cannot open HTTP, even if the client's approval resets a flag. + if (operationGeneration != generation) throw new IOException("GrowthTrack operation was cancelled"); + URL endpoint = new URL(GrowthTrackProtocol.API + path); + connection = (HttpsURLConnection) (network == null ? endpoint.openConnection() : network.openConnection(endpoint)); active = connection; + } + try { + connection.setInstanceFollowRedirects(false); connection.setUseCaches(false); connection.setConnectTimeout(15_000); connection.setReadTimeout(20_000); + connection.setRequestMethod(method); + for (Map.Entry h : headers.entrySet()) connection.setRequestProperty(h.getKey(), h.getValue()); + if (body != null) { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + if (bytes.length > GrowthTrackProtocol.MAX_BYTES) throw new IOException("GrowthTrack request exceeds its byte limit"); + connection.setDoOutput(true); connection.setFixedLengthStreamingMode(bytes.length); + try (java.io.OutputStream out = connection.getOutputStream()) { out.write(bytes); } + } + int status = connection.getResponseCode(); + if (status >= 300 && status < 400) throw new IOException("GrowthTrack unexpectedly redirected the request"); + InputStream stream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String response = ""; + if (stream != null) try (InputStream in = stream; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; int n; + while ((n = in.read(buffer)) != -1) { if (out.size() + n > 2_000_000) throw new IOException("GrowthTrack response exceeds its byte limit"); out.write(buffer, 0, n); } + response = new String(out.toByteArray(), StandardCharsets.UTF_8); + } + return new GrowthTrackClient.Response(status, response, connection.getHeaderField("Retry-After")); + } catch (IOException e) { throw new IOException("GrowthTrack network request failed"); } + finally { connection.disconnect(); if (active == connection) active = null; } + } +} diff --git a/app/src/main/java/org/openpump/GrowthTrackManager.java b/app/src/main/java/org/openpump/GrowthTrackManager.java new file mode 100644 index 0000000..e946045 --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackManager.java @@ -0,0 +1,122 @@ +package org.openpump; + +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** One serial worker per process: rotating refresh credentials can never race. */ +final class GrowthTrackManager { + interface Work { Object run(GrowthTrackClient client) throws Exception; } + interface Listener { void done(Object result, String error); } + static final class SyncRequest { volatile boolean stopped; } + private volatile SyncRequest activeSync; + private final Handler main = new Handler(Looper.getMainLooper()); + private static final int SYNC_JOB = 72041; + private static GrowthTrackManager instance; + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + private final Context context; + private final GrowthTrackStore store; + private final GrowthTrackHttp http = new GrowthTrackHttp(); + private volatile GrowthTrackClient client; + private volatile boolean blocked; + private volatile String connectedGrant = ""; + static synchronized GrowthTrackManager get(Context c) { if (instance == null) instance = new GrowthTrackManager(c.getApplicationContext()); return instance; } + private GrowthTrackManager(Context c) { context = c; store = new GrowthTrackStore(c); } + private GrowthTrackClient client() throws IOException { + if (client == null) client = new GrowthTrackClient(store, http, new GrowthTrackClient.Clock() { @Override public long now() { return System.currentTimeMillis(); } }); + return client; + } + /** Main-thread observation only; this non-secret identity is frozen in the filed record. */ + String connectionId() { return blocked ? "" : connectedGrant; } + private void cacheConnection(GrowthTrackClient ready) { + GrowthTrackClient.Snapshot state = ready.snapshot(); + connectedGrant = !blocked && state.connected ? state.connectionId : ""; + } + void run(final Work work, final Listener listener) { dispatch(work, listener, null); } + private void dispatch(final Work work, final Listener listener, final SyncRequest request) { + final long ticket = http.ticket(); + worker.execute(new Runnable() { @Override public void run() { + Object result = null; String error = null; GrowthTrackClient ready = null; + try { + if (request != null && request.stopped) throw new IOException("Background sync was stopped"); + activeSync = request; + if (blocked) throw new IOException("Connection cleanup is in progress"); + http.enter(ticket); ready = client(); http.enter(ticket); cacheConnection(ready); + if (request != null && request.stopped) throw new IOException("Background sync was stopped"); + http.useNetwork(null); result = work.run(ready); + } + catch (GrowthTrackClient.Problem e) { error = e.getMessage(); } + catch (IOException e) { error = e.getMessage(); } + catch (Exception e) { error = "Could not complete the GrowthTrack operation. No credential details are logged."; } + http.useNetwork(null); activeSync = null; + if (ready != null) cacheConnection(ready); + // Finish a running JobService before scheduling its next one-shot job. + if (listener != null) listener.done(result, error); + if (request != null && !request.stopped && ready != null && !blocked) { + try { http.enter(ticket); schedule(ready); } catch (IOException ignored) { } + } + } }); + } + /** Only records marked at completion for this exact grant can recover into the encrypted queue. */ + SyncRequest startSync(final Listener listener) { return startSync(listener, null); } + SyncRequest startSync(final Listener listener, final android.net.Network network) { + final SyncRequest request = new SyncRequest(); + dispatch(new Work() { @Override public Object run(GrowthTrackClient ready) throws Exception { + GrowthTrackClient.Snapshot state = ready.snapshot(); + if (!state.connected) return null; + http.useNetwork(network); ready.prepareAutomaticSync(); + Model recorded = Store.load(context, false); + for (Model.Sess session : recorded.sessLog.all) { + if (state.connectionId.equals(session.growthTrackConnectionId)) + ready.recordCompleted(session, Store.loadAsRun(context, session.ts), recorded, session.growthTrackConnectionId); + } + ready.syncPending(); return null; + } }, listener, request); + return request; + } + private JobScheduler jobs() { return (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); } + private void schedule(final GrowthTrackClient ready) { + final long due = ready.automaticRetryAt(); final long ticket = http.ticket(); + // jobFinished posts its completion on the main looper. Schedule after that + // message so replacing this job ID cannot stop the job that just finished. + main.post(new Runnable() { @Override public void run() { + if (blocked) return; + try { http.enter(ticket); } catch (IOException ignored) { return; } + JobScheduler scheduler = jobs(); + if (due < 0) { if (scheduler != null) scheduler.cancel(SYNC_JOB); return; } + int result = JobScheduler.RESULT_FAILURE; + try { + if (scheduler != null) result = scheduler.schedule(new JobInfo.Builder(SYNC_JOB, new ComponentName(context, GrowthTrackSyncService.class)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setMinimumLatency(Math.max(1000L, due - System.currentTimeMillis())) + .setBackoffCriteria(30_000L, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setPersisted(true).build()); + } catch (RuntimeException ignored) { /* A scheduler refusal remains visible in sync status. */ } + if (result != JobScheduler.RESULT_SUCCESS) worker.execute(new Runnable() { @Override public void run() { + if (!blocked && client == ready) try { ready.backgroundUnavailable(); } catch (IOException ignored) { } + } }); + } }); + } + void cancelSync(SyncRequest request) { + if (request == null) return; request.stopped = true; + // A stopped queued job must not cancel an unrelated OAuth/routine operation. + if (activeSync == request) cancel(); + } + void cancel() { GrowthTrackClient c = client; if (c != null) c.cancelRequests(); http.cancel(); } + void reset(final Listener listener) { + blocked = true; connectedGrant = ""; cancel(); JobScheduler scheduler = jobs(); if (scheduler != null) scheduler.cancel(SYNC_JOB); + worker.execute(new Runnable() { @Override public void run() { + String error = null; + try { store.erase(); client = null; blocked = false; } + catch (IOException e) { error = e.getMessage(); } // Keep requests blocked until reset succeeds. + if (listener != null) listener.done(null, error); + } }); + } + static synchronized void eraseWithAppData(Context c) { get(c).reset(null); } +} diff --git a/app/src/main/java/org/openpump/GrowthTrackStore.java b/app/src/main/java/org/openpump/GrowthTrackStore.java new file mode 100644 index 0000000..7abd48f --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackStore.java @@ -0,0 +1,72 @@ +package org.openpump; + +import android.content.Context; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.AtomicFile; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import org.json.JSONObject; + +/** Keystore-backed AES-GCM; tokens and approved records never enter preferences or backups. */ +final class GrowthTrackStore implements GrowthTrackClient.Store { + private static final String ALIAS = "org.openpump.growthtrack.v1"; + private static final int MAX_STORED_BYTES = 16_000_000; + private final AtomicFile file; + GrowthTrackStore(Context context) { file = new AtomicFile(new File(context.getNoBackupFilesDir(), "growthtrack-v1.enc")); } + @Override public JSONObject read() throws IOException { + if (!file.getBaseFile().exists() && !new File(file.getBaseFile().getPath() + ".bak").exists()) return new JSONObject(); + try (FileInputStream in = file.openRead()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int n; + while ((n = in.read(buffer)) != -1) { if (out.size() + n > MAX_STORED_BYTES) throw new IOException(); out.write(buffer, 0, n); } + byte[] blob = out.toByteArray(); + if (blob.length < 32 || blob[0] != 'G' || blob[1] != 'T' || blob[2] != 1) throw new IOException(); + byte[] iv = java.util.Arrays.copyOfRange(blob, 3, 15); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key(false), new GCMParameterSpec(128, iv)); + cipher.updateAAD(new byte[]{'G', 'T', 1}); + byte[] clear = cipher.doFinal(blob, 15, blob.length - 15); + try { return new JSONObject(new String(clear, StandardCharsets.UTF_8)); } + finally { java.util.Arrays.fill(clear, (byte) 0); } + } catch (Exception e) { throw new IOException("Secure GrowthTrack storage is unavailable. Disconnect/reset the connection to link again."); } + } + @Override public void write(JSONObject state) throws IOException { + FileOutputStream out = null; byte[] clear = state.toString().getBytes(StandardCharsets.UTF_8); + try { + if (clear.length > MAX_STORED_BYTES - 32) throw new IOException(); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key(true)); + cipher.updateAAD(new byte[]{'G', 'T', 1}); byte[] iv = cipher.getIV(); + if (iv.length != 12) throw new IOException(); + byte[] encrypted = cipher.doFinal(clear); + out = file.startWrite(); out.write(new byte[]{'G', 'T', 1}); out.write(iv); out.write(encrypted); file.finishWrite(out); out = null; + } catch (Exception e) { if (out != null) file.failWrite(out); throw new IOException("Could not securely save the GrowthTrack connection. No plain-text fallback is used."); } + finally { java.util.Arrays.fill(clear, (byte) 0); } + } + private static SecretKey key(boolean create) throws Exception { + KeyStore store = KeyStore.getInstance("AndroidKeyStore"); store.load(null); + if (store.containsAlias(ALIAS)) return (SecretKey) store.getKey(ALIAS, null); + if (!create) throw new IOException(); + KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); + generator.init(new KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256).setRandomizedEncryptionRequired(true).build()); + return generator.generateKey(); + } + void erase() throws IOException { + file.delete(); + boolean remains = file.getBaseFile().exists() || new File(file.getBaseFile().getPath() + ".bak").exists() + || new File(file.getBaseFile().getPath() + ".new").exists(); + // Destroy the decryption key even if the filesystem could not remove an old blob. + try { KeyStore store = KeyStore.getInstance("AndroidKeyStore"); store.load(null); if (store.containsAlias(ALIAS)) store.deleteEntry(ALIAS); } + catch (Exception e) { throw new IOException("Could not remove the GrowthTrack encryption key"); } + if (remains) throw new IOException("Could not remove GrowthTrack connection storage. Retry reset before linking again."); + } +} diff --git a/app/src/main/java/org/openpump/GrowthTrackSyncService.java b/app/src/main/java/org/openpump/GrowthTrackSyncService.java new file mode 100644 index 0000000..aaa72a6 --- /dev/null +++ b/app/src/main/java/org/openpump/GrowthTrackSyncService.java @@ -0,0 +1,35 @@ +package org.openpump; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.os.Handler; +import android.os.Looper; + +/** Android network-constrained retry. No UI, new OAuth grant, or pump access. */ +public final class GrowthTrackSyncService extends JobService { + private JobParameters active; + private GrowthTrackManager.SyncRequest request; + private final Handler main = new Handler(Looper.getMainLooper()); + @Override public boolean onStartJob(JobParameters params) { + active = params; + request = GrowthTrackManager.get(this).startSync(new Finished(params), android.os.Build.VERSION.SDK_INT >= 28 ? params.getNetwork() : null); + return true; + } + private final class Finished implements GrowthTrackManager.Listener { + final JobParameters params; Finished(JobParameters params) { this.params = params; } + @Override public void done(Object result, String error) { + // The worker needs jobFinished to precede replacement scheduling. Android's + // JobService API accepts this callback from the worker; no view is touched. + jobFinished(params, false); + main.post(new Clear(params)); + } + } + private final class Clear implements Runnable { + final JobParameters params; Clear(JobParameters params) { this.params = params; } + @Override public void run() { if (active == params) active = null; } + } + @Override public boolean onStopJob(JobParameters params) { + if (active == params) { active = null; GrowthTrackManager.get(this).cancelSync(request); request = null; } + return true; + } +} diff --git a/app/src/main/java/org/openpump/SessionActivity.java b/app/src/main/java/org/openpump/SessionActivity.java index 5af510f..b638121 100644 --- a/app/src/main/java/org/openpump/SessionActivity.java +++ b/app/src/main/java/org/openpump/SessionActivity.java @@ -540,6 +540,7 @@ private Model.Set manualSourceSet() { Model.Fmt.unit = model.unit; // keep the formatter in step with what was saved Model.Fmt.sizeUnit = model.sizeUnit; // ...and the SIZE formatter, the same way Model.Fmt.loadUnit = model.loadUnit; // ...and the LOAD formatter (2026-09-30) + GrowthTrackManager.get(this).startSync(null); openLog(); storeUnreadable = Store.storeWasUnreadable(); // KEEP THE PHOTOS OUT OF THE PHONE'S GALLERY. The directory below is @@ -868,6 +869,12 @@ private Model.Set manualSourceSet() { * The running screen, for the guard at the top of onCreate. Main thread only; set as a * screen is created and released in its onDestroy only if it is still this one. */ private static SessionActivity liveScreen; + + /** Main-thread, read-only guard for optional connected-app handoffs. */ + static SessionActivity liveForDataHandoff() { + SessionActivity s = liveScreen; + return s != null && !s.isFinishing() && !s.isDestroyed() ? s : null; + } /** This instance was created over a running screen and finished at once: its onDestroy * must touch nothing (no last stop, no clearLive, no service stop). */ private boolean duplicateScreen; @@ -2711,7 +2718,7 @@ void applySecureWindow() { * its switch is on. Incognito (0.10): the switch is "Hide from recent apps" in Settings › * Privacy - the same field, the same flag, and ONLY this switch sets it (WiringCheck * invariant 193). */ - void applySecureFlag(android.view.Window w) { + static void applySecureFlag(android.view.Window w, Model model) { if (w == null) return; if (model != null && model.secureWindow) w.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); @@ -2719,6 +2726,8 @@ void applySecureFlag(android.view.Window w) { w.clearFlags(WindowManager.LayoutParams.FLAG_SECURE); } + void applySecureFlag(android.view.Window w) { applySecureFlag(w, model); } + /* ================================================================ INCOGNITO (0.10) * * The part of Settings › Privacy that lives on this screen. The rules are Incognito's @@ -27953,6 +27962,12 @@ private void fileSession(boolean aborted, long now) { // ...and with the earlier parts, the session lasts them all and starts when the first // did - before anything below keys on rec.ts (the as-run, the traces, the day). RunParts.foldTime(parts, rec); + // Data-only wall-clock facts: do not reinterpret old history's end/start key. + // A rejoined record lacks independently preserved wall-clock bounds for all parts. + if (parts == null || parts.playedMs == 0) { + rec.recordedStartMs = session.runStartedAtOrZero(); + rec.recordedEndMs = now; + } el = rec.durSec; sessionElapsedSec = el; rec.completed = !aborted; @@ -28213,7 +28228,10 @@ private void fileSession(boolean aborted, long now) { // it is where the summary's Done goes, and the summary is drawn after this runs. manualSource = null; model.adhoc.clear(); + GrowthTrackManager sync = GrowthTrackManager.get(this); + rec.growthTrackConnectionId = sync.connectionId(); Store.save(this, model); + sync.startSync(null); // The run is over: stop attributing samples to it and stop its clock. What it // accumulated stays readable (the summary is redrawn after the after-measurement @@ -35336,6 +35354,7 @@ void refreshWidgetIdle() { /** Set at load, so the warning survives the rest of onCreate deciding what to show. */ private boolean storeUnreadable = false; + boolean dataStoreUnreadable() { return storeUnreadable; } /** * THE SCREEN THAT USED TO NOT EXIST (audit A33). A model.json that could not be read diff --git a/app/src/main/java/org/openpump/SettingsScreen.java b/app/src/main/java/org/openpump/SettingsScreen.java index bf4201d..72d3c52 100644 --- a/app/src/main/java/org/openpump/SettingsScreen.java +++ b/app/src/main/java/org/openpump/SettingsScreen.java @@ -1029,6 +1029,12 @@ void showSettings() { * out of sight. */ // Whether this build has the diagnostic console at all: only a debug build does (see // the Diagnostics card below). + LinearLayout gConnected = Ui.cardGroup(a, a.body, "Connected apps", null, Ui.DIM); + Ui.note(a, gConnected, "GrowthTrack is optional. Connected: new sessions sync automatically. " + + "Disconnected: nothing sends."); + Button growthTrack = Ui.flat(a, gConnected, "GrowthTrack >"); + growthTrack.setOnClickListener(new GrowthTrackActivity.OpenTap(a, null)); + boolean console = SessionActivity.hasConsole(); /* ===== DEVICE & DEVELOPER — deliberately last ================================== */ diff --git a/app/src/main/java/org/openpump/SetupScreen.java b/app/src/main/java/org/openpump/SetupScreen.java index aaf2d5d..c3bda2f 100644 --- a/app/src/main/java/org/openpump/SetupScreen.java +++ b/app/src/main/java/org/openpump/SetupScreen.java @@ -80,6 +80,7 @@ static final class Walk { final HashSet open = new HashSet(); /** The battery row as last drawn, so a resume redraws only when it changed. */ boolean drawnExempt; + boolean growthTrackVisible; Walk() { // The one explanation open from the start: the battery's, on Ready. @@ -932,6 +933,11 @@ private int starterMinutes() { private void ready() { Walk w = w(); + if (GrowthTrackOnboarding.shouldOffer(a.model, RunService.isRunning(), a.dataStoreUnreadable())) { + GrowthTrackOnboarding.presented(a.model); + Store.save(a, a.model); + w.growthTrackVisible = true; + } TextView h = text(SetupText.READY_TITLE, 22f, Ui.TEXT); h.setTypeface(Typeface.DEFAULT_BOLD); if (android.os.Build.VERSION.SDK_INT >= 28) h.setAccessibilityHeading(true); @@ -939,6 +945,20 @@ private void ready() { hl.bottomMargin = dp(Look.S4); page.addView(h, hl); + if (w.growthTrackVisible) { + LinearLayout gt = card(); + Ui.head(a, gt, "Connect your own GrowthTrack account?"); + Ui.note(a, gt, "GrowthTrack records PE training sessions, routines and measurements. Connection is optional; you can skip and connect later in Settings."); + Ui.noteInfo(a, gt, "Nothing uploads just by connecting.", "Your choice", + "Use your own account and approve in your browser. While connected, every new real finished session syncs automatically. Disconnected sessions and earlier history stay on your phone. Skipping, cancelling or going back leaves OpenPump ready to use."); + Button connect = Ui.flat(a, gt, "Connect to GrowthTrack"); + connect.setOnClickListener(new GrowthTrackChoice(true)); + Button info = Ui.flat(a, gt, "About GrowthTrack and account setup"); + info.setOnClickListener(new GrowthTrackActivity.InformationTap(a)); + Button skip = Ui.flat(a, gt, "Skip for now"); + skip.setOnClickListener(new GrowthTrackChoice(false)); + } + // THE BATTERY: the one explanation open from the start, and the one row that goes // out to a system screen - so the answer is read again on the way back (onResume). boolean exempt = a.isBatteryExempt(); @@ -982,6 +1002,16 @@ private void ready() { page.addView(opts, ol); } + private final class GrowthTrackChoice implements View.OnClickListener { + final boolean connect; + GrowthTrackChoice(boolean connect) { this.connect = connect; } + @Override public void onClick(View view) { + w().growthTrackVisible = false; + show(); + if (connect) GrowthTrackActivity.open(a, null); + } + } + private void summary(LinearLayout parent) { Model m = a.model; String size = Model.Fmt.S_IN.equals(m.sizeUnit) ? SetupText.SIZE_IN : SetupText.SIZE_CM; diff --git a/app/src/main/java/org/openpump/Store.java b/app/src/main/java/org/openpump/Store.java index f18c8aa..6ca1bba 100644 --- a/app/src/main/java/org/openpump/Store.java +++ b/app/src/main/java/org/openpump/Store.java @@ -538,6 +538,7 @@ public static void deleteTau(Context c, long sessTs) { * the same either way, and a half-completed erase must still leave the app usable. */ public static boolean deleteAll(Context c) { + GrowthTrackManager.eraseWithAppData(c); // stop transfers and remove tokens outside backups too File dir = c.getFilesDir(); boolean ok = true; ok &= gone(new File(dir, FILE)); diff --git a/app/src/main/java/org/openpump/SummaryScreen.java b/app/src/main/java/org/openpump/SummaryScreen.java index afc9692..896b311 100644 --- a/app/src/main/java/org/openpump/SummaryScreen.java +++ b/app/src/main/java/org/openpump/SummaryScreen.java @@ -314,6 +314,11 @@ void renderSummary() { a.streakBlock(st.streak, "day streak", sub, st.weekDays, st.scheduledDays); } + if (s != null && !s.sim) { + Button growthTrack = Ui.flat(a, a.body, "GrowthTrack sync status"); + growthTrack.setOnClickListener(new GrowthTrackActivity.OpenTap(a, s)); + } + boolean man = s != null && s.manual; // The after-session measurement now leads the summary rather than closing it - see // measureAfterCard(). One action, one place: a second identical button down here diff --git a/core/src/main/java/org/openpump/AsRun.java b/core/src/main/java/org/openpump/AsRun.java index c12c1cd..dce6949 100644 --- a/core/src/main/java/org/openpump/AsRun.java +++ b/core/src/main/java/org/openpump/AsRun.java @@ -71,6 +71,9 @@ public static final class Row { /** Position of the set within its stage (Preset#pos) and position of the preset * within the set's ladder (Preset#ordinal). */ public int pos, ordinal; + /** Frozen stitch boundary: -1 on old recordings, 0 a complete preset, 1 a + * continuation chunk. Data only; never changes how a preset is driven. */ + public int cyclePart = -1; public int up, upReq, lo, uhWire, uhReq, lh, sp; /** False when the pump never acknowledged the write this row records (§2) — the * card then says "the pump didn't confirm this change". */ @@ -113,6 +116,7 @@ public JSONObject toJson() throws JSONException { // shim has no optLong, and one reader for both JVMs is the point of the shim. o.put("t0", String.valueOf(t0)); o.put("t1", String.valueOf(t1)); o.put("st", stageIdx); o.put("pos", pos); o.put("ord", ordinal); + if (cyclePart >= 0) o.put("cyclePart", cyclePart); o.put("sid", setId == null ? "" : setId); o.put("up", up); o.put("upr", upReq); o.put("lo", lo); o.put("uhw", uhWire); o.put("uhr", uhReq); o.put("lh", lh); o.put("sp", sp); @@ -128,6 +132,8 @@ public static Row fromJson(JSONObject o) { r.t0 = parseLong(o.optString("t0", "0"), 0L); r.t1 = parseLong(o.optString("t1", "-1"), -1L); r.stageIdx = o.optInt("st", 0); r.pos = o.optInt("pos", 0); r.ordinal = o.optInt("ord", 0); + int part = o.optInt("cyclePart", -1); + r.cyclePart = part == 0 || part == 1 ? part : -1; r.setId = o.optString("sid", ""); r.up = o.optInt("up", 0); // upReq absent (a row written before it existed) → what went on the wire: no @@ -248,9 +254,11 @@ public Row openRow() { */ public Row open(int kind, long t0, Model.Preset p, int up, int upReq, int lo, int uhWire, int uhReq, int lh, int sp) { - return open(kind, t0, p == null ? 0 : p.stageIdx, p == null ? 0 : p.pos, + Row row = open(kind, t0, p == null ? 0 : p.stageIdx, p == null ? 0 : p.pos, p == null ? "" : p.setId, p == null ? 0 : p.ordinal, up, upReq, lo, uhWire, uhReq, lh, sp); + if (p != null && row.hasValues()) row.cyclePart = p.cyclePart ? 1 : 0; + return row; } /** {@link #open(int, long, Model.Preset, int, int, int, int, int, int, int)} with the diff --git a/core/src/main/java/org/openpump/GrowthTrackClient.java b/core/src/main/java/org/openpump/GrowthTrackClient.java new file mode 100644 index 0000000..6251f9f --- /dev/null +++ b/core/src/main/java/org/openpump/GrowthTrackClient.java @@ -0,0 +1,407 @@ +package org.openpump; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** Serialized OAuth and upload state machine. Transport and encrypted storage are Android seams. */ +public final class GrowthTrackClient { + public interface Store { JSONObject read() throws IOException; void write(JSONObject state) throws IOException; } + public interface Clock { long now(); } + public interface Transport { Response request(String method, String path, Map headers, String body) throws IOException; } + public static final class Response { + public final int status; + public final String body, retryAfter; + public Response(int status, String body) { this(status, body, null); } + public Response(int status, String body, String retryAfter) { this.status = status; this.body = body; this.retryAfter = retryAfter; } + } + public static final class Problem extends IOException { + public Problem(String message) { super(message); } + } + public static final class Snapshot { + public final boolean connected, routines, pending, paused; + public final int queued; + public final long retryAt; + public final String message; + public final String connectionId; + Snapshot(boolean connected, boolean routines, boolean pending, int queued, long retryAt, String message, String connectionId, boolean paused) { + this.connected = connected; this.routines = routines; this.pending = pending; + this.queued = queued; this.retryAt = retryAt; this.message = message; + this.connectionId = connectionId; this.paused = paused; + } + } + private final Store store; + private final Transport transport; + private final Clock clock; + private JSONObject state; + private volatile boolean cancelled; + private boolean storageBroken; + + public GrowthTrackClient(Store store, Transport transport, Clock clock) throws IOException { + this.store = store; this.transport = transport; this.clock = clock; + state = store.read(); + if (state == null) state = new JSONObject(); + if (state.optInt("version", 1) != 1) throw new Problem("This connection storage needs a newer OpenPump version"); + if (state.optBoolean("refreshing") || state.optBoolean("exchanging")) { + // A rotating token may have been consumed before the process died. Never replay it. + clearAuthority("Connection interrupted while exchanging credentials. Connect again."); save(); + } + if (state.optJSONArray("approved") == null) put("approved", new JSONArray()); + if (state.optJSONObject("receipts") == null) put("receipts", new JSONObject()); + if (state.optJSONObject("tokens") != null && state.optString("connection_id").isEmpty()) { + clearAuthority("This older connection needs fresh browser approval."); save(); + } + if (state.optJSONObject("tokens") != null && !state.optBoolean("automatic_sync")) { + clearAuthority("Connect again to enable automatic transfer of every new session."); save(); + } + if (approved().length() > 0 && !state.optString("connection_id").equals(state.optString("approved_connection_id"))) { + put("approved", new JSONArray()); put("message", "The account approval changed. Pending sessions for the previous account were discarded."); save(); + } + } + public synchronized Snapshot snapshot() { + JSONObject t = state.optJSONObject("tokens"); + return new Snapshot(t != null && !storageBroken, t != null && !storageBroken && hasScope(t.optString("scope"), GrowthTrackProtocol.READ), + state.optJSONObject("pending") != null, approved().length(), state.optLong("retry_at", 0), state.optString("message", "Not connected"), state.optString("connection_id"), state.optBoolean("sync_paused")); + } + public synchronized String receipt(String id) { return state.optJSONObject("receipts").optString(id, ""); } + public void cancelRequests() { cancelled = true; } + private void checkCancelled() throws Problem { if (storageBroken) throw new Problem("Secure storage failed. Disconnect/reset this connection before continuing."); if (cancelled) throw new Problem("Sending stopped. An in-flight request may already have arrived; stable IDs make a later retry safe."); } + + /** Changing account invalidates every old local approval; only future completions belong to the new grant. */ + public synchronized String begin(boolean routines) throws IOException { + if (storageBroken) throw new Problem("Secure storage failed. Disconnect/reset this connection before continuing."); + cancelled = false; + clearAuthority("Waiting for GrowthTrack approval in your browser"); + put("receipts", new JSONObject()); + put("pending", pendingJson(GrowthTrackProtocol.pending(routines, clock.now()))); save(); + try { return GrowthTrackProtocol.Pending.from(state.getJSONObject("pending")).authorizeUrl(); } + catch (JSONException e) { throw new Problem("Could not create the connection request"); } + } + public synchronized void cancelAuthorization() throws IOException { + state.remove("pending"); put("message", "Connection cancelled. No sessions were sent."); save(); + } + public synchronized void callback(String uri) throws IOException { + GrowthTrackProtocol.Pending pending; + GrowthTrackProtocol.Callback callback; + try { + pending = state.optJSONObject("pending") == null ? null : GrowthTrackProtocol.Pending.from(state.getJSONObject("pending")); + callback = GrowthTrackProtocol.callback(uri, pending, clock.now()); + } catch (JSONException | IllegalArgumentException e) { throw new Problem(e.getMessage()); } + checkCancelled(); + state.remove("pending"); + if (callback.error != null) { put("message", "access_denied".equals(callback.error) ? "GrowthTrack approval was declined. No sessions were sent." : "GrowthTrack could not approve this connection. Connect again."); save(); return; } + put("exchanging", true); save(); + Map form = new LinkedHashMap(); + form.put("grant_type", "authorization_code"); form.put("code", callback.code); + form.put("code_verifier", pending.verifier); form.put("client_id", GrowthTrackProtocol.CLIENT_ID); form.put("redirect_uri", GrowthTrackProtocol.REDIRECT); + try { + Response response = tokenRequest(form); checkCancelled(); + if (response.status != 200) throw new Problem(tokenError(response)); + put("tokens", tokens(response.body, pending.scope)); put("exchanging", false); + put("connection_id", pending.state); put("automatic_sync", true); put("sync_paused", false); + put("message", "Connected to the GrowthTrack account you approved in the browser. Every new real finished session will sync automatically. Earlier history stays on this device."); save(); + } catch (IOException e) { clearAuthority("Could not complete the connection. Connect again; the authorization code will not be replayed."); save(); throw e; } + } + public synchronized void disconnect() throws IOException { + cancelled = true; clearAuthority("Disconnected on this device. Remove OpenPump in GrowthTrack Connected apps to revoke its server approval."); + put("receipts", new JSONObject()); save(); + } + public synchronized void cancelApproved() throws IOException { + put("approved", new JSONArray()); put("retry_at", 0); put("attempts", 0); + put("message", "Sending stopped. Sessions already received by GrowthTrack remain there."); save(); + } + + /** Completion consent is the browser grant captured when the record was filed. + * Persisted record markers recover the tiny filing/queue crash window, without + * admitting disconnected history or records belonging to another account. */ + public synchronized boolean recordCompleted(Model.Sess session, AsRun run, Model model, String completionConnectionId) throws IOException { + if (!snapshot().connected || completionConnectionId == null || completionConnectionId.isEmpty() + || !completionConnectionId.equals(state.optString("connection_id"))) return false; + checkCancelled(); + String id; + try { id = GrowthTrackProtocol.stableId(session == null ? "" : session.id); } + catch (IllegalArgumentException e) { put("message", "Not synced: a finished record has no stable identifier."); save(); return false; } + if (!receipt(id).isEmpty() || ids(approved()).contains(id)) return false; + JSONObject payload; + try { + payload = GrowthTrackSession.draft(session, run, model).payload(); validateSession(payload); + if (envelope(new JSONArray().put(payload)).getBytes(StandardCharsets.UTF_8).length > GrowthTrackProtocol.MAX_BYTES) + throw new IllegalArgumentException("The recording exceeds GrowthTrack's request size limit"); + } catch (IllegalArgumentException | JSONException e) { + receiptPut(id, "Not synced: " + safeReason(e.getMessage() == null ? "Invalid recording" : e.getMessage())); + put("message", "A finished recording cannot be sent. See session sync status for the reason."); save(); return false; + } + approved().put(payload); put("approved_connection_id", completionConnectionId); + put("message", approved().length() + " sessions waiting for automatic sync"); save(); return true; + } + /** Read-only scheduling decision: a paused permission error never loops in background. */ + public synchronized long automaticRetryAt() { + Snapshot s = snapshot(); return s.connected && s.queued > 0 && !s.paused ? Math.max(clock.now(), s.retryAt) : -1; + } + public synchronized void backgroundUnavailable() throws IOException { + put("message", "Background sync could not be scheduled. Pending sessions are secure; reopen OpenPump or tap Retry to resume."); save(); + } + /** Called on the serial native worker after a fresh transport generation is entered. */ + public synchronized void prepareAutomaticSync() throws IOException { + if (storageBroken) throw new Problem("Secure storage failed. Disconnect/reset before continuing."); + cancelled = false; + } + /** Starts no OAuth and scans no unmarked history. Same-grant durable pending work only. */ + public synchronized void syncPending() throws IOException { + if (automaticRetryAt() < 0 || clock.now() < state.optLong("retry_at", 0)) return; + cancelled = false; sendApproved(); + } + + /** A detached, allowlisted snapshot of exactly the records the user just reviewed. */ + public synchronized void approve(List sessions) throws IOException { + approve(sessions, state.optString("connection_id")); + } + /** The review must still refer to the same browser grant, including across relinking. */ + public synchronized void approve(List sessions, String reviewedConnectionId) throws IOException { + checkLinked(GrowthTrackProtocol.WRITE); cancelled = false; + if (reviewedConnectionId == null || reviewedConnectionId.isEmpty() || !reviewedConnectionId.equals(state.optString("connection_id"))) + throw new Problem("The GrowthTrack account approval changed. Review these sessions again before sending."); + if (sessions == null || sessions.isEmpty()) throw new Problem("Select at least one session"); + if (approved().length() != 0) throw new Problem("Retry or cancel the existing approved send first"); + JSONArray queue = new JSONArray(); Set ids = new HashSet(); + try { + for (JSONObject s : sessions) { + validateSession(s); + String id = s.getString("external_session_id"); + if (!ids.add(id)) throw new Problem("The selection contains a duplicate session"); + if (envelope(new JSONArray().put(s)).getBytes(StandardCharsets.UTF_8).length > GrowthTrackProtocol.MAX_BYTES) + throw new Problem("A selected session exceeds GrowthTrack's request size limit"); + queue.put(new JSONObject(s.toString())); + } + } catch (JSONException | IllegalArgumentException e) { throw new Problem("The selected session payload is invalid"); } + put("approved", queue); put("attempts", 0); put("retry_at", 0); put("batch_limit", GrowthTrackProtocol.BATCH_SIZE); + put("approved_connection_id", reviewedConnectionId); + put("message", queue.length() + " reviewed sessions approved for sending"); save(); + } + /** Retry uses the same durable, account-bound queue; startup and scheduled jobs call syncPending(). */ + public synchronized void resumeApproved() throws IOException { put("sync_paused", false); cancelled = false; sendApproved(); } + public synchronized void sendApproved() throws IOException { + checkCancelled(); checkLinked(GrowthTrackProtocol.WRITE); + if (approved().length() > 0 && !state.optString("connection_id").equals(state.optString("approved_connection_id"))) + throw new Problem("The approved send belongs to an earlier connection. Cancel it and review your sessions again."); + if (clock.now() < state.optLong("retry_at", 0)) throw new Problem("Please wait for the retry delay before trying again"); + while (approved().length() > 0) { + checkCancelled(); + JSONArray batch = batch(); String body = envelope(batch); + Response response; + try { response = authorized("POST", "/partner-ingest", body, GrowthTrackProtocol.WRITE); } + catch (Problem e) { throw e; } + catch (IOException e) { retry(null); throw new Problem("Offline or network error. Your sessions are saved for automatic retry."); } + checkCancelled(); + if (response.status == 413 && batch.length() > 1) { + put("batch_limit", Math.max(1, batch.length() / 2)); save(); continue; + } + if (response.status == 408 || response.status == 429 || response.status >= 500) { + retry(response.retryAfter); throw new Problem("GrowthTrack is unavailable or busy. Your sessions are saved for automatic retry."); + } + if (response.status != 200) { + if (response.status == 400 || response.status == 413 || response.status == 422) { + for (int i = 0; i < batch.length(); i++) receiptPut(batch.optJSONObject(i).optString("external_session_id"), "Needs review: GrowthTrack rejected the request (" + response.status + ")"); + removeIds(ids(batch)); put("message", "GrowthTrack rejected the request. Review the selected sessions before sending again."); save(); + } + if (response.status != 400 && response.status != 413 && response.status != 422) { + put("sync_paused", true); put("message", "Sync paused: GrowthTrack rejected the request (" + response.status + "). Check the connection."); save(); + } + throw new Problem("GrowthTrack rejected the request (" + response.status + "). See sync status."); + } + try { applyResults(batch, new JSONObject(response.body)); } + catch (JSONException | IllegalArgumentException e) { retry(null); throw new Problem("GrowthTrack returned an incomplete result. The approved batch is kept; retrying is safe."); } + if (state.optLong("retry_at", 0) > clock.now()) return; + } + } + + public synchronized JSONObject routines(String id) throws IOException { + cancelled = false; + if (id != null && !id.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")) + throw new Problem("Invalid GrowthTrack routine ID"); + Response r = authorized("GET", "/partner-routines" + (id == null ? "" : "?id=" + id), null, GrowthTrackProtocol.READ); + checkCancelled(); + if (r.status == 404) throw new Problem("This routine is no longer available. Refresh the library."); + if (r.status != 200) throw new Problem("Could not read the GrowthTrack library (" + r.status + "). Try again later."); + try { + JSONObject o = new JSONObject(r.body); + if (!"gt-routine".equals(o.getString("format")) || o.getInt("schema_version") != 1) + throw new Problem("This GrowthTrack routine format is not supported"); + if (id == null) { if (o.getJSONArray("routines").length() > 1000) throw new JSONException("Too many routines"); } + else { JSONObject routine = o.getJSONObject("routine"); if (!id.equals(routine.getString("external_id")) || routine.getJSONArray("steps").length() > 1000) throw new JSONException("Invalid routine"); } + return o; + } catch (JSONException e) { throw new Problem("GrowthTrack returned an invalid routine response"); } + } + + private Response authorized(String method, String path, String body, String scope) throws IOException { + checkLinked(scope); + JSONObject t = state.optJSONObject("tokens"); + boolean refreshed = false; + if (clock.now() + 30_000 >= t.optLong("expires_at")) { refresh(); refreshed = true; } + Response r = authenticatedRequest(method, path, body); + if (r.status == 401 && !refreshed) { refresh(); r = authenticatedRequest(method, path, body); } + if (r.status == 401 || r.status == 410) { + clearAuthority("GrowthTrack access expired or was removed. Automatic sync stopped; connect again."); save(); + throw new Problem("GrowthTrack access expired or was removed. Connect again."); + } + if (r.status == 403) { put("sync_paused", true); put("message", "Sync paused: GrowthTrack has not granted " + scope + ". Reconnect to restore access."); save(); throw new Problem(state.optString("message")); } + return r; + } + private Response authenticatedRequest(String method, String path, String body) throws IOException { + checkCancelled(); JSONObject t = state.optJSONObject("tokens"); + Map headers = new LinkedHashMap(); + headers.put("Authorization", "Bearer " + t.optString("access_token")); + headers.put("Accept", "application/json"); headers.put("Cache-Control", "no-store"); + if (body != null) headers.put("Content-Type", "application/json; charset=utf-8"); + return transport.request(method, path, headers, body); + } + private void refresh() throws IOException { + checkCancelled(); JSONObject old = state.optJSONObject("tokens"); + if (old == null) throw new Problem("Connect to GrowthTrack first"); + put("refreshing", true); save(); + Map form = new LinkedHashMap(); + form.put("grant_type", "refresh_token"); form.put("refresh_token", old.optString("refresh_token")); form.put("client_id", GrowthTrackProtocol.CLIENT_ID); + try { + Response r = tokenRequest(form); checkCancelled(); + if (r.status != 200) throw new Problem(tokenError(r)); + JSONObject replacement = tokens(r.body, old.optString("scope")); + if (old.optString("refresh_token").equals(replacement.optString("refresh_token"))) throw new Problem("GrowthTrack did not rotate the refresh credential"); + put("tokens", replacement); put("refreshing", false); save(); + } catch (IOException e) { + clearAuthority("Could not safely renew GrowthTrack access. Connect again; the old refresh credential will not be replayed."); save(); + throw new Problem("Could not safely renew GrowthTrack access. Connect again."); + } + } + private Response tokenRequest(Map form) throws IOException { + Map headers = new LinkedHashMap(); + headers.put("Content-Type", "application/x-www-form-urlencoded"); headers.put("Accept", "application/json"); headers.put("Cache-Control", "no-store"); + return transport.request("POST", "/oauth-token", headers, GrowthTrackProtocol.form(form)); + } + private JSONObject tokens(String body, String requestedScope) throws Problem { + try { + JSONObject o = new JSONObject(body); String access = o.getString("access_token"), refresh = o.getString("refresh_token"); + int expiry = o.getInt("expires_in"); String scope = o.getString("scope"); + if (!access.matches("gta_[A-Za-z0-9_-]{20,256}") || !refresh.matches("gtr_[A-Za-z0-9_-]{20,256}") + || !"Bearer".equalsIgnoreCase(o.getString("token_type")) || expiry < 1 || expiry > 900 + || !hasScope(scope, GrowthTrackProtocol.WRITE)) throw new JSONException("Invalid credentials"); + for (String s : scope.trim().split("\\s+")) if (!hasScope(requestedScope, s)) throw new JSONException("Unexpected permission"); + if (hasScope(requestedScope, GrowthTrackProtocol.READ) && !hasScope(scope, GrowthTrackProtocol.READ)) + throw new JSONException("Missing permission"); + return new JSONObject().put("access_token", access).put("refresh_token", refresh).put("scope", scope) + .put("expires_at", clock.now() + expiry * 1000L); + } catch (JSONException e) { throw new Problem("GrowthTrack returned invalid credentials. Connect again."); } + } + private String tokenError(Response r) { + String code = ""; try { code = new JSONObject(r.body).optString("error"); } catch (JSONException ignored) { } + if ("invalid_client".equals(code)) return "GrowthTrack has not enabled this OpenPump client. Contact the integration maintainer."; + if ("invalid_grant".equals(code)) return "GrowthTrack authorization expired or was removed. Connect again."; + return "GrowthTrack could not complete authorization (" + r.status + "). Connect again."; + } + private void applyResults(JSONArray batch, JSONObject response) throws JSONException, IOException { + int accepted = count(response, "accepted"), duplicates = count(response, "duplicates"); + JSONArray rejected = response.getJSONArray("rejected"); Set submitted = ids(batch), seen = new HashSet(); + Map reasons = new LinkedHashMap(); + for (int i = 0; i < rejected.length(); i++) { + JSONObject item = rejected.getJSONObject(i); String id = item.getString("external_session_id"); + if (!submitted.contains(id) || !seen.add(id)) throw new JSONException("Invalid result IDs"); + String reason = item.getString("reason"); + reasons.put(id, reason.equals("Insert failed") || reason.contains("retry later") ? "retry" : "Needs review: " + safeReason(reason)); + } + if ((long) accepted + duplicates + rejected.length() != batch.length()) throw new JSONException("Incomplete result counts"); + Set finished = new HashSet(); boolean retry = false; + for (String id : submitted) { + String reason = reasons.get(id); + if ("retry".equals(reason)) { retry = true; continue; } + receiptPut(id, reason == null ? "Received by GrowthTrack (sent or already present)" : reason); finished.add(id); + } + removeIds(finished); + put("message", accepted + " accepted, " + duplicates + " already present, " + rejected.length() + " rejected."); + if (!retry) put("attempts", 0); + put("retry_at", 0); save(); + if (retry) retry(null); + } + private static int count(JSONObject o, String field) throws JSONException { + Object value = o.get(field); if (!(value instanceof Number)) throw new JSONException("Invalid count"); + double n = ((Number) value).doubleValue(); if (!Double.isFinite(n) || n < 0 || n > Integer.MAX_VALUE || Math.floor(n) != n) throw new JSONException("Invalid count"); + return (int) n; + } + private JSONArray batch() throws IOException { + JSONArray out = new JSONArray(); int limit = Math.max(1, state.optInt("batch_limit", GrowthTrackProtocol.BATCH_SIZE)); + for (int i = 0; i < approved().length() && i < limit; i++) { + out.put(approved().optJSONObject(i)); + if (envelope(out).getBytes(StandardCharsets.UTF_8).length > GrowthTrackProtocol.MAX_BYTES) { + JSONArray smaller = new JSONArray(); for (int j = 0; j < out.length() - 1; j++) smaller.put(out.optJSONObject(j)); + if (smaller.length() == 0) throw new Problem("An approved session exceeds the request size limit. Cancel and review it."); + return smaller; + } + } + return out; + } + private String envelope(JSONArray list) throws Problem { try { return new JSONObject().put("schema_version", 1).put("sessions", list).toString(); } catch (JSONException e) { throw new Problem("Could not prepare the upload"); } } + private static Set ids(JSONArray rows) { Set out = new HashSet(); for (int i = 0; i < rows.length(); i++) out.add(rows.optJSONObject(i).optString("external_session_id")); return out; } + private JSONArray approved() { return state.optJSONArray("approved"); } + private void removeIds(Set ids) throws Problem { JSONArray remaining = new JSONArray(); for (int i = 0; i < approved().length(); i++) if (!ids.contains(approved().optJSONObject(i).optString("external_session_id"))) remaining.put(approved().optJSONObject(i)); put("approved", remaining); } + private void receiptPut(String id, String text) throws Problem { try { state.optJSONObject("receipts").put(id, text); } catch (JSONException e) { throw new Problem("Could not save the upload result"); } } + private void retry(String retryAfter) throws IOException { + int attempts = Math.min(10, state.optInt("attempts", 0) + 1); long delay = Math.min(3_600_000L, 5000L << (attempts - 1)); + if (retryAfter != null) try { delay = Math.max(delay, Math.min(86_400L, Math.max(0, Long.parseLong(retryAfter))) * 1000L); } catch (NumberFormatException ignored) { } + put("attempts", attempts); put("retry_at", clock.now() + delay); put("message", "Sessions are securely queued. Automatic retry is waiting for the connection or retry delay."); save(); + } + private void checkLinked(String scope) throws Problem { if (storageBroken) throw new Problem("Secure storage failed. Disconnect/reset this connection before continuing."); JSONObject t = state.optJSONObject("tokens"); if (t == null) throw new Problem("Connect to GrowthTrack first"); if (!hasScope(t.optString("scope"), scope)) throw new Problem("Connect again to allow " + scope); } + private static boolean hasScope(String scopes, String scope) { for (String s : scopes.trim().split("\\s+")) if (scope.equals(s)) return true; return false; } + private void clearAuthority(String message) throws Problem { state.remove("tokens"); state.remove("pending"); state.remove("connection_id"); state.remove("approved_connection_id"); put("refreshing", false); put("exchanging", false); put("automatic_sync", false); put("sync_paused", false); put("approved", new JSONArray()); put("retry_at", 0); put("message", message); } + private JSONObject pendingJson(GrowthTrackProtocol.Pending p) throws Problem { try { return p.json(); } catch (JSONException e) { throw new Problem("Could not save authorization"); } } + private void put(String key, Object value) throws Problem { try { state.put(key, value); } catch (JSONException e) { throw new Problem("Could not save connection state"); } } + private void save() throws IOException { put("version", 1); try { store.write(new JSONObjectCopy(state).value); } catch (IOException e) { storageBroken = true; cancelled = true; throw e; } } + private static final class JSONObjectCopy { final JSONObject value; JSONObjectCopy(JSONObject original) throws Problem { try { value = new JSONObject(original.toString()); } catch (JSONException e) { throw new Problem("Could not save connection state"); } } } + private static String safeReason(String reason) { return reason.replaceAll("(?:gta|gtr|gtc|gtp)_[A-Za-z0-9_-]+", "[redacted]").substring(0, Math.min(200, reason.replaceAll("(?:gta|gtr|gtc|gtp)_[A-Za-z0-9_-]+", "[redacted]").length())); } + private static void validateSession(JSONObject s) throws JSONException { + Set allowed = new HashSet(java.util.Arrays.asList("external_session_id", "started_at", "completed_at", "routine_name", "total_duration_seconds", "time_under_tension_seconds", "pressure_peak_inhg", "method", "sets")); + for (java.util.Iterator it = s.keys(); it.hasNext();) if (!allowed.contains(it.next())) throw new JSONException("Unapproved session field"); + if (s.getString("external_session_id").length() > 128 || s.getString("external_session_id").isEmpty() + || s.getString("routine_name").isEmpty() || s.getString("routine_name").length() > 200) throw new JSONException("Invalid session identity"); + long started = timestamp(s.getString("started_at")), completed = timestamp(s.getString("completed_at")); + if (completed <= started) throw new JSONException("Invalid session timestamps"); + int seconds = count(s, "total_duration_seconds"); if (seconds < 1 || seconds > 86400) throw new JSONException("Invalid duration"); + if (s.has("time_under_tension_seconds")) { int tension = count(s, "time_under_tension_seconds"); if (tension < 1 || tension > seconds) throw new JSONException("Invalid time under tension"); } + if (s.has("pressure_peak_inhg")) { + Object value = s.get("pressure_peak_inhg"); + if (!(value instanceof Number)) throw new JSONException("Invalid pressure"); + double peak = ((Number) value).doubleValue(); + if (!Double.isFinite(peak) || peak < 0 || peak > 29.92) throw new JSONException("Invalid pressure"); + } + JSONArray sets = s.optJSONArray("sets"); + if (sets == null || sets.length() == 0) { if (!GrowthTrackSession.method(s.getString("method"))) throw new JSONException("Invalid method"); } + else { + if (sets.length() > 500) throw new JSONException("Too many sets"); + long totalSetSeconds = 0; Set indexes = new HashSet(); + for (int i = 0; i < sets.length(); i++) { + JSONObject set = sets.getJSONObject(i); + for (java.util.Iterator it = set.keys(); it.hasNext();) if (!java.util.Arrays.asList("method", "set_index", "duration_seconds").contains(it.next())) throw new JSONException("Unapproved set field"); + int index = count(set, "set_index"); + if (index < 1 || !indexes.add(index) || !GrowthTrackSession.method(set.getString("method"))) throw new JSONException("Invalid set"); + if (!set.isNull("duration_seconds")) { + int duration = count(set, "duration_seconds"); if (duration < 1 || duration > seconds) throw new JSONException("Invalid set duration"); + totalSetSeconds += duration; + } + } + if (totalSetSeconds > seconds) throw new JSONException("Set durations exceed the session duration"); + } + } + private static long timestamp(String text) throws JSONException { + if (!text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z")) throw new JSONException("Invalid timestamp"); + java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.ROOT); + format.setLenient(false); format.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.text.ParsePosition pos = new java.text.ParsePosition(0); java.util.Date date = format.parse(text, pos); + if (date == null || pos.getIndex() != text.length()) throw new JSONException("Invalid timestamp"); + return date.getTime(); + } +} diff --git a/core/src/main/java/org/openpump/GrowthTrackOnboarding.java b/core/src/main/java/org/openpump/GrowthTrackOnboarding.java new file mode 100644 index 0000000..c89e56d --- /dev/null +++ b/core/src/main/java/org/openpump/GrowthTrackOnboarding.java @@ -0,0 +1,13 @@ +package org.openpump; + +/** A one-time invitation only. It grants no authority and never queues or starts a run. */ +public final class GrowthTrackOnboarding { + private GrowthTrackOnboarding() { } + public static boolean shouldOffer(Model m, boolean runLive, boolean unreadable) { + if (m.growthTrackOfferSeen || runLive || unreadable) return false; + for (Model.Sess s : m.sessLog.all) if (!s.sim) return false; + return true; + } + /** Mark when displayed: Back, dismissal or process death must not become repeated nags. */ + public static void presented(Model m) { m.growthTrackOfferSeen = true; } +} diff --git a/core/src/main/java/org/openpump/GrowthTrackProtocol.java b/core/src/main/java/org/openpump/GrowthTrackProtocol.java new file mode 100644 index 0000000..153cc6a --- /dev/null +++ b/core/src/main/java/org/openpump/GrowthTrackProtocol.java @@ -0,0 +1,127 @@ +package org.openpump; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.LinkedHashMap; +import java.util.Map; +import org.json.JSONException; +import org.json.JSONObject; + +/** The public native-client contract. No partner key or GrowthTrack user JWT belongs here. */ +public final class GrowthTrackProtocol { + private GrowthTrackProtocol() { } + public static final String API = "https://rxtqbktyeetmbigbpbdd.supabase.co/functions/v1"; + public static final String CLIENT_ID = "gtc_bfa5809efbd314f246dd5d0f"; + public static final String REDIRECT = "gt-openpump://oauth/callback"; + public static final String CONNECTED_APPS = "https://pe-growth-track.com/connected-apps"; + public static final String WRITE = "sessions:write", READ = "routines:read"; + public static final int MAX_BYTES = 1_000_000; + // Below both the older pilot's 100 and the current handler's 500-session limit. + public static final int BATCH_SIZE = 100; + public static final long AUTH_LIFETIME_MS = 15 * 60_000L; + + public static final class Pending { + public final String state, verifier, scope; + public final long createdAt; + Pending(String state, String verifier, String scope, long createdAt) { + this.state = state; this.verifier = verifier; this.scope = scope; this.createdAt = createdAt; + } + public JSONObject json() throws JSONException { + return new JSONObject().put("state", state).put("verifier", verifier) + .put("scope", scope).put("created_at", createdAt); + } + public static Pending from(JSONObject o) throws JSONException { + String state = o.getString("state"), verifier = o.getString("verifier"); + if (!state.matches("[a-f0-9]{64}") || !verifier.matches("[A-Za-z0-9._~-]{43,128}")) + throw new JSONException("Invalid saved authorization"); + String scope = o.getString("scope"); + if (!WRITE.equals(scope) && !(WRITE + " " + READ).equals(scope)) + throw new JSONException("Invalid saved scope"); + return new Pending(state, verifier, scope, o.getLong("created_at")); + } + public String authorizeUrl() { + Map p = new LinkedHashMap(); + p.put("response_type", "code"); p.put("client_id", CLIENT_ID); p.put("redirect_uri", REDIRECT); + p.put("scope", scope); p.put("state", state); p.put("code_challenge", challenge(verifier)); + p.put("code_challenge_method", "S256"); + return API + "/oauth-authorize?" + form(p); + } + } + + public static Pending pending(boolean routines, long now) { + SecureRandom random = new SecureRandom(); + byte[] a = new byte[32], b = new byte[32]; random.nextBytes(a); random.nextBytes(b); + return new Pending(hex(a), hex(b), WRITE + (routines ? " " + READ : ""), now); + } + public static String challenge(String verifier) { + if (verifier == null || !verifier.matches("[A-Za-z0-9._~-]{43,128}")) + throw new IllegalArgumentException("Invalid PKCE verifier"); + return base64Url(digest(verifier)); + } + public static String stableId(String id) { + if (id == null || id.trim().isEmpty()) throw new IllegalArgumentException("This record has no stable session ID"); + return id.length() <= 125 ? "op_" + id : "op_" + hex(digest(id)); + } + + public static final class Callback { + public final String code, error; + Callback(String code, String error) { this.code = code; this.error = error; } + } + /** Validate the exact callback and state before consuming anything or making a request. */ + public static Callback callback(String value, Pending pending, long now) { + if (pending == null) throw new IllegalArgumentException("No connection request is waiting"); + if (now < pending.createdAt || now - pending.createdAt > AUTH_LIFETIME_MS) + throw new IllegalArgumentException("Connection request expired. Connect again"); + if (value == null || value.length() > 8192) throw new IllegalArgumentException("Invalid connection callback"); + try { + URI u = new URI(value); + if (!"gt-openpump".equals(u.getScheme()) || !"oauth".equals(u.getHost()) + || !"/callback".equals(u.getRawPath()) || u.getPort() != -1 + || u.getUserInfo() != null || u.getFragment() != null) + throw new IllegalArgumentException("Unexpected connection callback"); + Map p = new LinkedHashMap(); + if (u.getRawQuery() == null) throw new IllegalArgumentException("Missing connection response"); + for (String pair : u.getRawQuery().split("&", -1)) { + String[] parts = pair.split("=", 2); + String key = decode(parts[0]), val = parts.length == 2 ? decode(parts[1]) : ""; + if (!("state".equals(key) || "code".equals(key) || "error".equals(key) || "error_description".equals(key)) + || p.put(key, val) != null) throw new IllegalArgumentException("Invalid connection response parameters"); + } + String state = p.get("state"); + if (state == null || !MessageDigest.isEqual(pending.state.getBytes(StandardCharsets.UTF_8), + state.getBytes(StandardCharsets.UTF_8))) throw new IllegalArgumentException("Connection response did not match this request"); + String code = p.get("code"), error = p.get("error"); + if ((code == null) == (error == null)) throw new IllegalArgumentException("Invalid connection response"); + if (code != null && !code.matches("gtc_[A-Za-z0-9_-]{20,256}")) + throw new IllegalArgumentException("Invalid authorization code"); + if (error != null && !error.matches("[a-z_]{1,64}")) throw new IllegalArgumentException("Invalid authorization response"); + return new Callback(code, error); + } catch (IllegalArgumentException e) { throw e; } + catch (Exception e) { throw new IllegalArgumentException("Invalid connection callback"); } + } + + public static String form(Map values) { + StringBuilder out = new StringBuilder(); + for (Map.Entry e : values.entrySet()) { + if (out.length() != 0) out.append('&'); + out.append(encode(e.getKey())).append('=').append(encode(e.getValue())); + } + return out.toString(); + } + private static String encode(String s) { try { return URLEncoder.encode(s, "UTF-8"); } catch (Exception e) { throw new AssertionError(e); } } + private static String decode(String s) { try { return URLDecoder.decode(s, "UTF-8"); } catch (Exception e) { throw new IllegalArgumentException("Invalid callback encoding"); } } + private static byte[] digest(String s) { try { return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { throw new AssertionError(e); } } + private static String hex(byte[] bytes) { StringBuilder s = new StringBuilder(); for (byte b : bytes) s.append(String.format(java.util.Locale.ROOT, "%02x", b & 255)); return s.toString(); } + // java.util.Base64 is API 26; the application also supports Android 24 and 25. + private static String base64Url(byte[] data) { + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + StringBuilder out = new StringBuilder(); int bits = 0, buffer = 0; + for (byte b : data) { buffer = (buffer << 8) | (b & 255); bits += 8; while (bits >= 6) { bits -= 6; out.append(alphabet.charAt((buffer >>> bits) & 63)); } } + if (bits > 0) out.append(alphabet.charAt((buffer << (6 - bits)) & 63)); + return out.toString(); + } +} diff --git a/core/src/main/java/org/openpump/GrowthTrackSession.java b/core/src/main/java/org/openpump/GrowthTrackSession.java new file mode 100644 index 0000000..9dc350f --- /dev/null +++ b/core/src/main/java/org/openpump/GrowthTrackSession.java @@ -0,0 +1,172 @@ +package org.openpump; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** An allowlist mapper, deliberately independent of Model's broad persistence/export JSON. */ +public final class GrowthTrackSession { + private GrowthTrackSession() { } + public static final String[] METHODS = { + "static_pumping", "interval_pumping", "rip", "sleeved_pumping", "length_pumping", + "milking", "high_pressure_milking", "pac", "airlock_clamping", "clamping", "hard_clamping", + "soft_clamping", "hypoxic_clamping", "manual_clamping", "clamped_ulis", "ulis", + "hanging", "static_hanging", "interval_hanging", "bundle_hanging", "fulcrum_hanging", "rapid_interval_hanging", + "extending", "static_extending", "interval_extending", "bundle_extending", "rapid_interval_vacuum_extending", + "jelqing", "manuals", "fowfers", "bundled_stretching", "mandingo_stretch", "fulcrum_bends", + "semi_erect_bends", "vibra_tugging", "kegels", "reverse_kegels", "angion_1", "angion_2", "angion_3", "angion_4", + "sabre", "warm_up", "massage", "edging", "ads", "bfr", "fire_goat_rolls", "tunica_shears" + }; + public static boolean method(String slug) { for (String m : METHODS) if (m.equals(slug)) return true; return false; } + + /** Positive UPPER hold duration, excluding release/rest. Classify before any wire rounding. */ + public static String methodForHoldMillis(double holdMs) { + if (!Double.isFinite(holdMs) || holdMs <= 0) throw new IllegalArgumentException("A positive recorded hold duration is required"); + if (holdMs <= 6000) return "milking"; + if (holdMs <= 30000) return "rip"; + if (holdMs <= 120000) return "interval_pumping"; + return "static_pumping"; + } + public static String methodLabel(String method) { + if ("milking".equals(method)) return "Milking"; + if ("rip".equals(method)) return "RIP"; + if ("interval_pumping".equals(method)) return "Interval pumping"; + if ("static_pumping".equals(method)) return "Static pumping"; + if ("length_pumping".equals(method)) return "Length pumping"; + return method.replace('_', ' '); + } + + public static final class Part { + public final String label, method; + public final int seconds; + public final long recordedMillis, holdMillis; + Part(String label, long recordedMillis, long holdMillis, String method) { + this.label = label; this.recordedMillis = recordedMillis; this.seconds = (int) (recordedMillis / 1000L); + this.holdMillis = holdMillis; this.method = method; + } + } + public static final class Draft { + public final String id, name, startedAt, completedAt; + public final int seconds; + public final Double observedPeakInhg; + public final Integer tensionSeconds; + public final List parts = new ArrayList(); + public final List warnings = new ArrayList(); + private String wholeMethod; + public String methodForWholeSession() { return wholeMethod; } + Draft(Model.Sess s) { + if (s == null || s.sim) throw new IllegalArgumentException("Simulated sessions cannot be sent to GrowthTrack"); + id = GrowthTrackProtocol.stableId(s.id); + if (s.recordedStartMs <= 0 || s.recordedEndMs <= s.recordedStartMs || s.durSec < 1 || s.durSec > 86400 + || s.durSec * 1000L > s.recordedEndMs - s.recordedStartMs + 1000L) + throw new IllegalArgumentException("Original wall-clock session boundaries are unavailable or inconsistent; older or rejoined history cannot be sent"); + name = s.routineName == null || s.routineName.trim().isEmpty() ? "OpenPump session" : s.routineName.trim(); + if (name.length() > 200) throw new IllegalArgumentException("Routine name is longer than GrowthTrack's 200-character limit"); + seconds = (int) s.durSec; + // Only explicitly recorded wall-clock facts, never the ambiguous legacy day/key stamp. + startedAt = iso(s.recordedStartMs); completedAt = iso(s.recordedEndMs); + double peak = s.peakKpa == null ? Double.NaN : s.peakKpa / 3.38639; + observedPeakInhg = Double.isFinite(peak) && peak >= 0 && peak <= 29.92 ? peak : null; + if (s.peakKpa != null && observedPeakInhg == null) warnings.add("The implausible observed peak is omitted."); + // Gross is sealed elapsed time INCLUDING releases. Net is the saved measured, + // phase-filtered training time; never substitute gross or commanded duty cycle. + double tup = s.netTupSec == null ? Double.NaN : s.netTupSec; + tensionSeconds = Double.isFinite(tup) && tup >= 1 && tup <= seconds ? (int) Math.floor(tup) : null; + if (s.netTupSec != null && tensionSeconds == null) warnings.add("The unavailable or inconsistent measured net time under pressure is omitted."); + if (!s.completed) warnings.add("This was stopped early. Only its recorded elapsed work is sent."); + } + public JSONObject payload() throws JSONException { + if (parts.isEmpty() && wholeMethod == null) throw new IllegalArgumentException("No classifiable recorded work exists for this session"); + JSONObject o = new JSONObject().put("external_session_id", id).put("started_at", startedAt) + .put("completed_at", completedAt).put("routine_name", name).put("total_duration_seconds", seconds); + if (observedPeakInhg != null) o.put("pressure_peak_inhg", observedPeakInhg); + if (tensionSeconds != null) o.put("time_under_tension_seconds", tensionSeconds); + if (wholeMethod != null) { o.put("method", wholeMethod); return o; } + JSONArray sets = new JSONArray(); + for (int i = 0; i < parts.size(); i++) { + Part part = parts.get(i); + JSONObject set = new JSONObject().put("set_index", i + 1).put("method", part.method); + // GT v1 only accepts whole positive seconds. Null preserves a subsecond + // segment's technique without fabricating a second of delivered work. + set.put("duration_seconds", part.seconds > 0 ? part.seconds : JSONObject.NULL); + sets.put(set); + } + o.put("sets", sets); + return o; + } + } + + public static Draft draft(Model.Sess s, AsRun run) { + return draft(s, run, null); + } + public static Draft draft(Model.Sess s, AsRun run, Model model) { + Draft draft = new Draft(s); + Model.AsRunSnapshot snap = run != null && run.snap != null ? run.snap : s.asRunSnapshot; + boolean length = snap != null && snap.trainerTrack == Plan.TRACK_LENGTH; + // Legacy snapshots have no frozen marker. Only use a structured marker on a + // routine whose full saved structure/values still match, never a free-text name. + if (snap != null && snap.trainerTrack < 0 && model != null) { + Model.Routine routine = model.routine(s.routineId); + length = routine != null && routine.trainerTrack == Plan.TRACK_LENGTH && snap.matches(routine, model); + } + if (run == null || run.rows.isEmpty()) { + if (length) { + draft.wholeMethod = "length_pumping"; + draft.warnings.add("The recorded Length track labels this sitting. No detailed set breakdown exists; only saved session elapsed/net figures are sent, with no invented work set."); + return draft; + } + throw new IllegalArgumentException("Detailed hold recording is unavailable; an automatic technique cannot be determined"); + } + LinkedHashMap durations = new LinkedHashMap(); + LinkedHashMap names = new LinkedHashMap(); + LinkedHashMap holds = new LinkedHashMap(); + LinkedHashMap methods = new LinkedHashMap(); + long previousEnd = 0; + for (AsRun.Row row : run.rows) { + if (row.kind == AsRun.SKIPPED) continue; + if (row.t0 < 0 || row.t1 < row.t0 || row.t0 < previousEnd || row.t1 > s.durSec * 1000L + 1000L) + throw new IllegalArgumentException("The detailed recording has inconsistent timing"); + previousEnd = row.t1; + if (!row.hasValues() || row.span() == 0) continue; + if (!row.confirmed) { draft.warnings.add("An unconfirmed pump change is excluded from the set breakdown."); continue; } + if (row.up <= 0) { draft.warnings.add("A zero-pressure part is excluded from the work breakdown."); continue; } + } + for (GrowthTrackTiming.Span span : GrowthTrackTiming.read(run, snap, length)) { + AsRun.Row row = span.row; + long holdMs = span.holdMs; + String method = length ? "length_pumping" : methodForHoldMillis(holdMs); + String key = row.stageIdx + ":" + row.pos + ":" + row.setId + ":" + method + ":" + holdMs; + Long before = durations.get(key); + durations.put(key, (before == null ? 0L : before) + span.workMs); + String label = "Recorded set " + (row.pos + 1); + if (snap != null && row.stageIdx >= 0 && row.stageIdx < snap.stages.size()) { + Model.AsRunSnapshot.StageSnap stage = snap.stages.get(row.stageIdx); + Model.AsRunSnapshot.SetSnap set = stage.setAt(row.pos); + label = stage.name + (set != null && set.name != null && !set.name.isEmpty() ? " / " + set.name : ""); + } + names.put(key, label); + holds.put(key, holdMs); methods.put(key, method); + } + int total = 0; + for (String key : durations.keySet()) { + int seconds = (int) (durations.get(key) / 1000L); + draft.parts.add(new Part(names.get(key), durations.get(key), holds.get(key), methods.get(key))); total += seconds; + } + if (draft.parts.size() > 500 || total > draft.seconds) throw new IllegalArgumentException("The recorded set breakdown exceeds GrowthTrack's limits"); + if (draft.parts.isEmpty()) throw new IllegalArgumentException("No confirmed positive-pressure work exists to send"); + draft.warnings.add(length ? "The structured Length track overrides hold-duration labels for every part." : "Techniques use confirmed command intervals, clipped to delivered recording time and joined across verified stitch boundaries: up to 6 s Milking; up to 30 s RIP; up to 120 s Interval pumping; longer Static pumping. Release/rest do not affect this label."); + draft.warnings.add("Set durations retain recorded work spans, including their release halves. Separate rest and interrupted periods are excluded. Measured net time under pressure excludes release/rest; gross sealed time is not substituted."); + draft.warnings.add("Command phases are reconstructed from confirmed holds/releases and elapsed spans, not sampled pressure plateaus. Ambiguous paused, lost-link or unverified stitched recordings are unavailable for automatic export. No measured plateau or pressure average is invented. GrowthTrack's whole-second durations round down; subsecond set duration is left unknown."); + return draft; + } + private static String iso(long millis) { + SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT); + f.setTimeZone(TimeZone.getTimeZone("UTC")); return f.format(new java.util.Date(millis)); + } +} diff --git a/core/src/main/java/org/openpump/GrowthTrackTiming.java b/core/src/main/java/org/openpump/GrowthTrackTiming.java new file mode 100644 index 0000000..115df35 --- /dev/null +++ b/core/src/main/java/org/openpump/GrowthTrackTiming.java @@ -0,0 +1,73 @@ +package org.openpump; + +import java.util.ArrayList; +import java.util.List; + +/** Reads confirmed command intervals; it does not infer measured pressure plateaus. */ +final class GrowthTrackTiming { + static final class Span { + final AsRun.Row row; final long workMs, holdMs; + Span(AsRun.Row row, long workMs, long holdMs) { this.row = row; this.workMs = workMs; this.holdMs = holdMs; } + } + static List read(AsRun run, Model.AsRunSnapshot snap, boolean length) { + List out = new ArrayList(); + List planned = Model.planFromSnapshot(snap); + long carryHold = 0, carryWork = 0; + AsRun.Row previous = null, carryRow = null; + for (AsRun.Row row : run.rows) { + if (row.kind == AsRun.SKIPPED) continue; + if (!length && row.span() > 0 && (row.kind == AsRun.HOLD || row.kind == AsRun.LOST)) + throw unknown("a pause or lost link does not retain the pressure-phase timeline"); + if (!row.hasValues() || row.span() == 0 || !row.confirmed || row.up <= 0) { + if (carryHold > 0) throw unknown("a stitch continuation is missing or unconfirmed"); + continue; + } + if (length) { out.add(new Span(row, row.span(), 0)); continue; } + if (row.uhWire <= 0 || row.lh < 0) throw unknown("a positive upper hold is unavailable"); + int part = part(row, planned); + if (part < 0 && row.uhWire == Proto.WIRE_HOLD_MAX && row.lh == 1 && row.lo == row.up - 1) + throw unknown("this older wire-sized hold has no verified stitch boundary"); + if (carryHold > 0 && (previous == null || row.t0 != previous.t1 || row.up != previous.up + || row.stageIdx != previous.stageIdx || row.pos != previous.pos || !row.setId.equals(previous.setId) + || row.ordinal != previous.ordinal + 1 || row.kind != AsRun.PLAN)) + throw unknown("a stitched hold was interrupted or changed"); + if (part == 1) { + if (row.kind != AsRun.PLAN || row.uhWire != Proto.WIRE_HOLD_MAX || row.lh != 1 + || row.lo != row.up - 1 || row.span() > row.uhWire * 1000L) + throw unknown("the delivered stitch no longer matches its recorded boundary"); + if (carryRow == null) carryRow = row; + carryHold += row.span(); carryWork += row.span(); previous = row; + continue; + } + long upper = row.uhWire * 1000L, release = row.lh * 1000L; + if (release == 0) { + // With no release the positive command is continuous across device repeats. + out.add(new Span(carryRow == null ? row : carryRow, carryWork + row.span(), carryHold + row.span())); + } else { + long remaining = row.span(); + while (remaining > 0) { + long held = Math.min(remaining, upper); + long work = Math.min(remaining, upper + release); + out.add(new Span(carryRow == null ? row : carryRow, carryWork + work, carryHold + held)); + carryHold = 0; carryWork = 0; carryRow = null; + remaining -= work; + } + } + carryHold = 0; carryWork = 0; carryRow = null; previous = row; + } + // A stopped final chunk retains only its recorded, clipped hold duration. + if (carryHold > 0) out.add(new Span(carryRow, carryWork, carryHold)); + return out; + } + private static int part(AsRun.Row row, List plan) { + if (row.cyclePart >= 0) return row.cyclePart; + if (row.kind != AsRun.PLAN) return -1; + for (Model.Preset p : plan) if (p.stageIdx == row.stageIdx && p.pos == row.pos && p.ordinal == row.ordinal + && p.setId.equals(row.setId) && p.uh == row.uhWire && p.uh == row.uhReq && p.lh == row.lh) + return p.cyclePart ? 1 : 0; + return -1; + } + private static IllegalArgumentException unknown(String reason) { + return new IllegalArgumentException("Automatic technique unavailable: " + reason + ". This recording cannot be sent."); + } +} diff --git a/core/src/main/java/org/openpump/Model.java b/core/src/main/java/org/openpump/Model.java index 970d8c5..c83cef8 100644 --- a/core/src/main/java/org/openpump/Model.java +++ b/core/src/main/java/org/openpump/Model.java @@ -4688,6 +4688,10 @@ private static int b64Val(char c) { */ public static final class AsRunSnapshot { public String routineId = ""; + /** Frozen structured track for connected-app classification. -1 means an old + * snapshot did not record it; 0 is a known unmarked routine. Never inferred + * from the routine/stage names or a later edited routine. */ + public int trainerTrack = -1; public final List stages = new ArrayList(); /** One stage as it stood at run start: its name, the set ids it held, in order, and @@ -4814,6 +4818,7 @@ public static AsRunSnapshot of(Routine r, Model m) { if (r == null) return null; AsRunSnapshot s = new AsRunSnapshot(); s.routineId = r.id == null ? "" : r.id; + s.trainerTrack = r.trainerTrack; for (int i = 0; i < r.stages.size(); i++) { Stage src = r.stages.get(i); StageSnap st = new StageSnap(); @@ -4874,6 +4879,7 @@ public boolean matches(Routine r, Model m) { public JSONObject toJson() throws JSONException { JSONObject o = new JSONObject(); o.put("rid", routineId); + if (trainerTrack >= 0) o.put("trainerTrack", trainerTrack); JSONArray a = new JSONArray(); for (int i = 0; i < stages.size(); i++) { StageSnap st = stages.get(i); @@ -4898,6 +4904,8 @@ public static AsRunSnapshot fromJson(JSONObject o) { if (o == null) return null; AsRunSnapshot s = new AsRunSnapshot(); s.routineId = o.optString("rid", ""); + s.trainerTrack = o.optInt("trainerTrack", -1); + if (s.trainerTrack < -1 || s.trainerTrack > 4) s.trainerTrack = -1; JSONArray a = o.optJSONArray("stages"); if (a != null) for (int i = 0; i < a.length(); i++) { JSONObject so = a.optJSONObject(i); @@ -4942,7 +4950,12 @@ public static AsRunSnapshot fromJson(JSONObject o) { public static final class Sess { public String id; public String routineId, routineName; - public long ts; // epoch millis at filing + public long ts; // legacy day/key stamp; old releases filed end, current releases start + /** Explicit wall-clock boundaries for optional partner export. Zero means unknown; + * never infer the meaning of an old ts or reconstruct gaps between rejoined parts. */ + public long recordedStartMs, recordedEndMs; + /** Non-secret browser-grant identity captured at filing; empty records never enter sync. */ + public String growthTrackConnectionId = ""; public long durSec; // the SAME elapsed the summary displayed public boolean completed; // false = stopped early (an attempt) /** True when this was a MANUAL run (Task 18) — an ephemeral cycle, not a routine. @@ -5419,6 +5432,9 @@ public JSONObject toJson() throws JSONException { // keeps the phone and the self-test on the same code path. o.put("ts", String.valueOf(ts)); o.put("dur", String.valueOf(durSec)); + if (recordedStartMs > 0) o.put("recordedStartMs", String.valueOf(recordedStartMs)); + if (recordedEndMs > 0) o.put("recordedEndMs", String.valueOf(recordedEndMs)); + if (!growthTrackConnectionId.isEmpty()) o.put("growthTrackConnectionId", growthTrackConnectionId); o.put("done", completed); o.put("man", manual); o.put("tag", tag == null ? "" : tag); @@ -5535,6 +5551,9 @@ public static Sess fromJson(JSONObject o) { s.countedTrack = o.optInt("countedTrack", TRAINER_TRACK_NONE); s.routineName = o.optString("rname", ""); s.ts = parseLong(o.optString("ts", "0")); + s.recordedStartMs = parseLong(o.optString("recordedStartMs", "0")); + s.recordedEndMs = parseLong(o.optString("recordedEndMs", "0")); + s.growthTrackConnectionId = o.optString("growthTrackConnectionId", ""); s.durSec = parseLong(o.optString("dur", "0")); s.completed = o.optBoolean("done"); // Absent (false) on every session filed before Task 18 — a routine run, which is @@ -7986,6 +8005,8 @@ public static int clampRunStripWhere(int w) { * restarting it; see {@link FirstRun}. */ public int firstRun = FirstRun.DONE; + /** Old phones have already set up; only seed creates an unshown optional invitation. */ + public boolean growthTrackOfferSeen = true; /** "Whole app" — gates the COLD OPEN only (the very first content this Activity shows * after a fresh launch), never re-checked for the rest of that process's lifetime. * See AppLock#isLocked's own doc for why this scope's re-lock rule differs from the @@ -11645,6 +11666,7 @@ public static double actualRatePerYear(long fromTs, double fromCm, /** What a brand-new install starts with. Nothing here is required to be kept. */ public static Model seed() { Model m = new Model(); + m.growthTrackOfferSeen = false; m.firstRun = FirstRun.NOT_STARTED; // the one place a setup is ever owed // One example per distinct behaviour the pump can run, so a fresh install shows what // each mode is rather than an empty library. All well inside the default 40 kPa @@ -11747,6 +11769,7 @@ public String toJson() { o.put("blur", privacyBlur); o.put("lockOn", appLockOn); o.put("firstRun", firstRun); + o.put("growthTrackOfferSeen", growthTrackOfferSeen); o.put("lockWhole", appLockWholeApp); o.put("lockPhotos", appLockPhotos); o.put("lockMeas", appLockMeasurements); @@ -12136,6 +12159,7 @@ public static Model fromJson(String s) { m.appLockOn = o.optBoolean("lockOn", false); // MIGRATION: absent in every file written before the first-run setup existed, and // those phones are all set up already - so absent means DONE, never "not started". + m.growthTrackOfferSeen = o.optBoolean("growthTrackOfferSeen", true); m.firstRun = Math.max(FirstRun.NOT_STARTED, Math.min(FirstRun.DONE, o.optInt("firstRun", FirstRun.DONE))); m.appLockWholeApp = o.optBoolean("lockWhole", false); diff --git a/core/src/test/java/Migrate.java b/core/src/test/java/Migrate.java index e845ca9..275c3e7 100644 --- a/core/src/test/java/Migrate.java +++ b/core/src/test/java/Migrate.java @@ -4,6 +4,42 @@ public class Migrate { // `throws Exception` only for the org.json constructor below, which is checked. public static void main(String[] a) throws Exception { + // GrowthTrack: old snapshots have no recorded designation, while new snapshots + // freeze the structured track and keep it through later routine edits/deletion. + Model.AsRunSnapshot gtOld = Model.AsRunSnapshot.fromJson( + new org.json.JSONObject("{\"rid\":\"legacy\",\"stages\":[]}")); + if (gtOld.trainerTrack != -1 || gtOld.toJson().has("trainerTrack")) + throw new AssertionError("Old track metadata must remain unknown"); + Model.Routine gtRoutine = new Model.Routine(); gtRoutine.id = "gt-fixture"; + gtRoutine.trainerTrack = org.openpump.Plan.TRACK_LENGTH; + Model.AsRunSnapshot gtFrozen = Model.AsRunSnapshot.of(gtRoutine, new Model()); + gtRoutine.trainerTrack = Model.TRAINER_TRACK_NONE; + if (Model.AsRunSnapshot.fromJson(gtFrozen.toJson()).trainerTrack != org.openpump.Plan.TRACK_LENGTH + || Model.AsRunSnapshot.fromJson(Model.AsRunSnapshot.of(gtRoutine, new Model()).toJson()).trainerTrack != 0) + throw new AssertionError("Frozen Length and known unmarked metadata must round-trip"); + System.out.println("GrowthTrack snapshot: old unknown / frozen Length / known unmarked preserved"); + + org.openpump.AsRun.Row gtOldRow = org.openpump.AsRun.Row.fromJson(new org.json.JSONObject()); + if (gtOldRow.cyclePart != -1 || gtOldRow.toJson().has("cyclePart")) throw new AssertionError("Old stitch boundary must remain unknown"); + gtOldRow.cyclePart = 1; + if (org.openpump.AsRun.Row.fromJson(gtOldRow.toJson()).cyclePart != 1) throw new AssertionError("Frozen stitch boundary must round-trip"); + Model gtFresh = Model.seed(); + if (gtFresh.growthTrackOfferSeen || !Model.fromJson("{\"sets\":[],\"routines\":[]}").growthTrackOfferSeen) throw new AssertionError("Optional invitation migration must never nag upgrades"); + org.openpump.GrowthTrackOnboarding.presented(gtFresh); + if (!Model.fromJson(gtFresh.toJson()).growthTrackOfferSeen) throw new AssertionError("Invitation dismissal must round-trip"); + System.out.println("GrowthTrack timing/onboarding: unknown old boundaries, frozen new boundaries, one-time offer preserved"); + + Model.Sess gtLegacyTime = Model.Sess.fromJson(new org.json.JSONObject("{\"ts\":\"1700000000000\",\"dur\":\"120\"}")); + if (gtLegacyTime.recordedStartMs != 0 || gtLegacyTime.recordedEndMs != 0) throw new AssertionError("Old wall-clock boundaries must not be invented"); + gtLegacyTime.recordedStartMs = 1700000000000L; gtLegacyTime.recordedEndMs = 1700000120000L; + Model.Sess gtTimeRoundTrip = Model.Sess.fromJson(gtLegacyTime.toJson()); + if (gtTimeRoundTrip.recordedStartMs != gtLegacyTime.recordedStartMs || gtTimeRoundTrip.recordedEndMs != gtLegacyTime.recordedEndMs) throw new AssertionError("Explicit wall-clock boundaries must round-trip"); + + if (!gtLegacyTime.growthTrackConnectionId.isEmpty()) throw new AssertionError("Legacy history must not opt into sync"); + gtLegacyTime.growthTrackConnectionId = "fictional-browser-grant"; + if (!Model.Sess.fromJson(gtLegacyTime.toJson()).growthTrackConnectionId.equals(gtLegacyTime.growthTrackConnectionId)) + throw new AssertionError("Completion grant marker must round-trip for crash recovery"); + // EXACTLY what build 30 (pre-stage) wrote to model.json String old = "{\"ceil\":40,\"unit\":\"inHg\",\"selected\":\"r1\"," + "\"sets\":[{\"id\":\"s1\",\"name\":\"Gentle Warm\",\"ramp\":false,\"up\":14," diff --git a/core/src/test/java/org/openpump/GrowthTrackAutoSyncTest.java b/core/src/test/java/org/openpump/GrowthTrackAutoSyncTest.java new file mode 100644 index 0000000..40206e4 --- /dev/null +++ b/core/src/test/java/org/openpump/GrowthTrackAutoSyncTest.java @@ -0,0 +1,116 @@ +package org.openpump; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Fictional completion events, encrypted-store seam and mocked HTTP only. */ +class GrowthTrackAutoSyncTest { + private final GrowthTrackIntegrationTest.Clock clock = new GrowthTrackIntegrationTest.Clock(); + private static AsRun run() { + AsRun run = new AsRun(); run.rows.add(GrowthTrackIntegrationTest.row(AsRun.PLAN, 0, 120_000, 0)); return run; + } + private static boolean complete(GrowthTrackIntegrationTest.Fixture f, String id) throws Exception { + return f.client.recordCompleted(GrowthTrackIntegrationTest.session(id), run(), new Model(), f.client.snapshot().connectionId); + } + @Test void connectedCompletionQueuesThenSyncsWithoutReviewOrSelection() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + f.http.acceptAll = true; + assertTrue(complete(f, "one")); assertEquals(1, f.client.snapshot().queued); + f.client.syncPending(); assertEquals(0, f.client.snapshot().queued); + assertEquals(1, f.http.requests.size()); assertTrue(f.client.receipt("op_one").startsWith("Received")); + } + @Test void everyDistinctCompletionAppendsEvenWithOtherSessionsPending() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + assertTrue(complete(f, "one")); assertTrue(complete(f, "two")); assertEquals(2, f.client.snapshot().queued); + f.http.acceptAll = true; f.client.syncPending(); + assertEquals(2, new JSONObject(f.http.requests.get(0).body).getJSONArray("sessions").length()); + } + @Test void duplicateCompletionBeforeAndAfterReceiptNeverCreatesAnotherTransfer() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); f.http.acceptAll = true; + assertTrue(complete(f, "one")); assertFalse(complete(f, "one")); assertEquals(1, f.client.snapshot().queued); + f.client.syncPending(); assertFalse(complete(f, "one")); f.client.syncPending(); assertEquals(1, f.http.requests.size()); + } + @Test void disconnectedCompletionAndPreconnectionHistoryNeverQueue() throws Exception { + GrowthTrackIntegrationTest.Storage store = new GrowthTrackIntegrationTest.Storage(); + GrowthTrackIntegrationTest.Http http = new GrowthTrackIntegrationTest.Http(); + GrowthTrackClient client = new GrowthTrackClient(store, http, clock); + assertFalse(client.recordCompleted(GrowthTrackIntegrationTest.session("before"), run(), new Model(), "")); + assertEquals(-1, client.automaticRetryAt()); client.syncPending(); assertTrue(http.requests.isEmpty()); + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + assertFalse(f.client.recordCompleted(GrowthTrackIntegrationTest.session("before"), run(), new Model(), "")); + assertEquals(0, f.client.snapshot().queued); + } + @Test void offlineQueueRestartsAndRetriesAfterDurableBackoff() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); complete(f, "one"); + f.http.responses.add(new IOException("fictional offline")); assertThrows(IOException.class, f.client::syncPending); + long retryAt = f.client.automaticRetryAt(); assertTrue(retryAt > f.clock.now()); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); + assertEquals(retryAt, restarted.automaticRetryAt()); restarted.syncPending(); assertEquals(1, f.http.requests.size()); + f.clock.time = retryAt; f.http.acceptAll = true; restarted.syncPending(); + assertEquals(0, restarted.snapshot().queued); assertEquals(2, f.http.requests.size()); + assertEquals("op_one", new JSONObject(f.http.requests.get(1).body).getJSONArray("sessions").getJSONObject(0).getString("external_session_id")); + } + @Test void filingMarkerRecoversCompletionLostBeforeEncryptedQueueSave() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + Model.Sess saved = GrowthTrackIntegrationTest.session("crash"); saved.growthTrackConnectionId = f.client.snapshot().connectionId; + saved = Model.Sess.fromJson(saved.toJson()); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); + assertTrue(restarted.recordCompleted(saved, run(), new Model(), saved.growthTrackConnectionId)); + f.http.acceptAll = true; restarted.syncPending(); assertTrue(restarted.receipt("op_crash").startsWith("Received")); + } + @Test void relinkDiscardsQueueAndNeverClaimsOldOrDisconnectedCompletions() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); String old = f.client.snapshot().connectionId; + complete(f, "old"); f.client.begin(false); assertEquals(0, f.client.snapshot().queued); + f.http.responses.add(GrowthTrackIntegrationTest.token(GrowthTrackIntegrationTest.R2, false)); + String state = f.storage.saved.getJSONObject("pending").getString("state"); + f.client.callback(GrowthTrackProtocol.REDIRECT + "?code=" + GrowthTrackIntegrationTest.CODE + "&state=" + state); + assertNotEquals(old, f.client.snapshot().connectionId); + assertFalse(f.client.recordCompleted(GrowthTrackIntegrationTest.session("old"), run(), new Model(), old)); + assertFalse(f.client.recordCompleted(GrowthTrackIntegrationTest.session("disconnected"), run(), new Model(), "")); + assertEquals(0, f.client.snapshot().queued); assertTrue(complete(f, "new")); + } + @Test void disconnectDuringTransferStopsRemainingBatchesAndRecovery() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + for (int i = 0; i < 101; i++) complete(f, "record" + i); + String old = f.client.snapshot().connectionId; f.http.acceptAll = true; + f.http.beforeReturn = () -> { try { f.client.disconnect(); } catch (IOException e) { throw new AssertionError(e); } }; + assertThrows(IOException.class, f.client::syncPending); assertEquals(1, f.http.requests.size()); + assertFalse(f.client.snapshot().connected); assertEquals(0, f.client.snapshot().queued); + assertFalse(f.client.recordCompleted(GrowthTrackIntegrationTest.session("late"), run(), new Model(), old)); + } + @Test void simulatedInvalidAndUnsupportedRecordsHaveDurableFailureStatuses() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); String grant = f.client.snapshot().connectionId; + Model.Sess sim = GrowthTrackIntegrationTest.session("sim"); sim.sim = true; + assertFalse(f.client.recordCompleted(sim, run(), new Model(), grant)); + Model.Sess invalid = GrowthTrackIntegrationTest.session("invalid"); invalid.recordedEndMs = 0; + assertFalse(f.client.recordCompleted(invalid, run(), new Model(), grant)); + assertFalse(f.client.recordCompleted(GrowthTrackIntegrationTest.session("unsupported"), null, new Model(), grant)); + assertEquals(0, f.client.snapshot().queued); assertTrue(f.http.requests.isEmpty()); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); + for (String id : new String[]{"sim", "invalid", "unsupported"}) assertTrue(restarted.receipt("op_" + id).startsWith("Not synced:")); + } + @Test void partialFailureRetainsOnlyTransientRecordAndRetriesAutomatically() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); + complete(f, "sent"); complete(f, "later"); complete(f, "bad"); + JSONArray rejected = new JSONArray().put(new JSONObject().put("external_session_id", "op_later").put("reason", "Insert failed")) + .put(new JSONObject().put("external_session_id", "op_bad").put("reason", "Invalid fixture field")); + f.http.responses.add(GrowthTrackIntegrationTest.ok(1, 0, rejected)); f.client.syncPending(); + assertEquals(1, f.client.snapshot().queued); assertTrue(f.client.receipt("op_bad").startsWith("Needs review")); + assertFalse(complete(f, "bad")); f.clock.time = f.client.automaticRetryAt(); f.http.acceptAll = true; f.client.syncPending(); + assertEquals("op_later", new JSONObject(f.http.requests.get(1).body).getJSONArray("sessions").getJSONObject(0).getString("external_session_id")); + } + @Test void permissionFailurePausesRatherThanSpinningBackgroundRequests() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); complete(f, "one"); + f.http.responses.add(new GrowthTrackClient.Response(403, "{}")); assertThrows(IOException.class, f.client::syncPending); + assertTrue(f.client.snapshot().paused); assertEquals(-1, f.client.automaticRetryAt()); + f.client.syncPending(); assertEquals(1, f.http.requests.size()); assertEquals(1, f.client.snapshot().queued); + } + @Test void manualConsentFromOlderBuildMustReconnectBeforeAutoSync() throws Exception { + GrowthTrackIntegrationTest.Fixture f = new GrowthTrackIntegrationTest.Fixture(false); f.storage.saved.remove("automatic_sync"); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); + assertFalse(restarted.snapshot().connected); assertEquals(-1, restarted.automaticRetryAt()); + } +} diff --git a/core/src/test/java/org/openpump/GrowthTrackIntegrationTest.java b/core/src/test/java/org/openpump/GrowthTrackIntegrationTest.java new file mode 100644 index 0000000..a48793e --- /dev/null +++ b/core/src/test/java/org/openpump/GrowthTrackIntegrationTest.java @@ -0,0 +1,430 @@ +package org.openpump; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Only fictional records and mocked HTTP/storage. These tests cannot reach GrowthTrack. */ +class GrowthTrackIntegrationTest { + static final String A = "gta_" + "a".repeat(43), R = "gtr_" + "r".repeat(43), R2 = "gtr_" + "s".repeat(43); + static final String CODE = "gtc_" + "c".repeat(43); + static class Clock implements GrowthTrackClient.Clock { long time = 1_700_000_000_000L; public long now() { return time; } } + static class Storage implements GrowthTrackClient.Store { + JSONObject saved = new JSONObject(); boolean fail; + public JSONObject read() throws IOException { try { return new JSONObject(saved.toString()); } catch (Exception e) { throw new IOException(); } } + public void write(JSONObject state) throws IOException { if (fail) throw new IOException("fixture storage failure"); try { saved = new JSONObject(state.toString()); } catch (Exception e) { throw new IOException(); } } + } + static class Request { + final String method, path, body; final Map headers; + Request(String method, String path, Map headers, String body) { this.method = method; this.path = path; this.headers = headers; this.body = body; } + } + static class Http implements GrowthTrackClient.Transport { + final Deque responses = new ArrayDeque<>(); final List requests = new ArrayList<>(); Runnable beforeReturn; boolean acceptAll; + public GrowthTrackClient.Response request(String method, String path, Map headers, String body) throws IOException { + requests.add(new Request(method, path, headers, body)); + if (beforeReturn != null) beforeReturn.run(); + if (acceptAll && path.equals("/partner-ingest")) try { return ok(new JSONObject(body).getJSONArray("sessions").length(), 0, new JSONArray()); } catch (Exception e) { throw new IOException(); } + Object response = responses.removeFirst(); if (response instanceof IOException) throw (IOException) response; + return (GrowthTrackClient.Response) response; + } + } + static class Fixture { + final Storage storage = new Storage(); final Http http = new Http(); final Clock clock = new Clock(); final GrowthTrackClient client; + Fixture(boolean routines) throws Exception { + client = new GrowthTrackClient(storage, http, clock); client.begin(routines); + String state = storage.saved.getJSONObject("pending").getString("state"); + http.responses.add(token(R, routines)); client.callback(GrowthTrackProtocol.REDIRECT + "?code=" + CODE + "&state=" + state); + http.requests.clear(); + } + void approve(String... ids) throws Exception { List list = new ArrayList<>(); for (String id : ids) list.add(payload(id)); client.approve(list); } + } + static GrowthTrackClient.Response token(String refresh, boolean routines) throws Exception { + return new GrowthTrackClient.Response(200, new JSONObject().put("access_token", A).put("refresh_token", refresh) + .put("token_type", "Bearer").put("expires_in", 900).put("scope", "sessions:write" + (routines ? " routines:read" : "")).toString()); + } + static GrowthTrackClient.Response ok(int accepted, int duplicates, JSONArray rejected) throws Exception { return new GrowthTrackClient.Response(200, new JSONObject().put("accepted", accepted).put("duplicates", duplicates).put("rejected", rejected).toString()); } + static Model.Sess session(String id) { + Model.Sess s = new Model.Sess(); s.id = id; s.ts = 1_700_000_000_000L; s.durSec = 120; s.routineName = "Fictional fixture"; s.completed = true; s.recordedStartMs = s.ts; s.recordedEndMs = s.ts + s.durSec * 1000L; + s.peakKpa = 33.8639; s.netTupSec = 60.8; s.grossTupSec = 110.0; s.note = "PRIVATE NOTE"; s.afterLenAbsCm = 999.0; s.afterGirAbsCm = 999.0; s.afterLenCm = 1.0; + return s; + } + static JSONObject payload(String id) throws Exception { AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120_000, 0)); return GrowthTrackSession.draft(session(id), run).payload(); } + static AsRun.Row row(int kind, long t0, long t1, int pos) { AsRun.Row r = new AsRun.Row(); r.kind = kind; r.t0 = t0; r.t1 = t1; r.pos = pos; r.setId = "set" + pos; r.up = 20; r.uhWire = 20; r.uhReq = 20; r.lh = 5; return r; } + + @Test void pkceMatchesRfc7636() { assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", GrowthTrackProtocol.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")); } + @Test void authorizationUsesOnlyPublicClientAndExactRedirect() { + GrowthTrackProtocol.Pending p = GrowthTrackProtocol.pending(true, 1000); + String url = p.authorizeUrl(); assertTrue(url.startsWith(GrowthTrackProtocol.API + "/oauth-authorize?")); + assertTrue(url.contains("client_id=" + GrowthTrackProtocol.CLIENT_ID)); assertTrue(url.contains("code_challenge_method=S256")); + assertFalse(url.contains("secret")); assertNotEquals(p.state, p.verifier); assertEquals(64, p.state.length()); + } + @Test void callbackRejectsWrongStateEndpointDuplicateAndMissingParameters() { + GrowthTrackProtocol.Pending p = GrowthTrackProtocol.pending(false, 1000); + String suffix = "?code=" + CODE + "&state=" + p.state; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback(GrowthTrackProtocol.REDIRECT + "?code=" + CODE + "&state=wrong", p, 1001)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback("gt-openpump://evil/callback" + suffix, p, 1001)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback(GrowthTrackProtocol.REDIRECT + suffix + "&state=" + p.state, p, 1001)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback(GrowthTrackProtocol.REDIRECT + suffix + "&access_token=bad", p, 1001)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback(GrowthTrackProtocol.REDIRECT + suffix + "#fragment", p, 1001)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackProtocol.callback(GrowthTrackProtocol.REDIRECT + suffix, p, 1_000 + GrowthTrackProtocol.AUTH_LIFETIME_MS + 1)); + } + @Test void denialAndCancelNeverExchangeOrSend() throws Exception { + Storage storage = new Storage(); Http http = new Http(); Clock clock = new Clock(); GrowthTrackClient client = new GrowthTrackClient(storage, http, clock); + client.begin(false); String state = storage.saved.getJSONObject("pending").getString("state"); + client.callback(GrowthTrackProtocol.REDIRECT + "?error=access_denied&state=" + state); + assertFalse(client.snapshot().connected); assertFalse(client.snapshot().pending); assertTrue(http.requests.isEmpty()); + client.begin(false); client.cancelAuthorization(); assertFalse(client.snapshot().pending); assertTrue(http.requests.isEmpty()); + } + @Test void forgedCallbackPreservesPendingRequestAndSendsNothing() throws Exception { + Storage store = new Storage(); Http http = new Http(); GrowthTrackClient c = new GrowthTrackClient(store, http, new Clock()); c.begin(false); + assertThrows(IOException.class, () -> c.callback(GrowthTrackProtocol.REDIRECT + "?code=" + CODE + "&state=forged")); + assertTrue(c.snapshot().pending); assertTrue(http.requests.isEmpty()); + } + @Test void lostCodeExchangeRequiresFreshApprovalAndIsNotReplayed() throws Exception { + Storage storage = new Storage(); Http http = new Http(); Clock clock = new Clock(); GrowthTrackClient c = new GrowthTrackClient(storage, http, clock); c.begin(false); + String callback = GrowthTrackProtocol.REDIRECT + "?code=" + CODE + "&state=" + storage.saved.getJSONObject("pending").getString("state"); + http.responses.add(new IOException()); assertThrows(IOException.class, () -> c.callback(callback)); + assertFalse(c.snapshot().pending); assertFalse(c.snapshot().connected); assertThrows(IOException.class, () -> c.callback(callback)); assertEquals(1, http.requests.size()); + } + @Test void mapperAllowlistUsesStartTimestampObservedPeakAndNoPrivateFields() throws Exception { + JSONObject p = payload("stable"); assertEquals("op_stable", p.getString("external_session_id")); assertEquals("2023-11-14T22:13:20.000Z", p.getString("started_at")); + assertEquals("2023-11-14T22:15:20.000Z", p.getString("completed_at")); assertEquals(10, p.getDouble("pressure_peak_inhg"), .000001); assertEquals(60, p.getInt("time_under_tension_seconds")); + for (String key : Arrays.asList("note", "measurements", "afterLenAbsCm", "user_id", "email", "link_id", "heat", "vibration", "pressure_inhg")) assertFalse(p.has(key), key); + assertFalse(p.toString().contains("PRIVATE")); assertFalse(p.toString().contains("999")); + } + @Test void simulatedUnidentifiedAndInvalidTimeRecordsAreNotExportable() { + Model.Sess s = session("x"); s.sim = true; assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, null)); + s.sim = false; s.id = ""; assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, null)); + s.id = "x"; s.durSec = 0; assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, null)); + } + @Test void oneSittingContainsFixedAutomaticMethodsAndExcludesPauseAndUnconfirmedRows() throws Exception { + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 30_000, 0)); run.rows.add(row(AsRun.REST, 30_000, 40_000, 0)); run.rows.add(row(AsRun.OVERRIDE, 40_000, 60_000, 0)); run.rows.add(row(AsRun.PLAN, 60_000, 90_000, 1)); + AsRun.Row unconfirmed = row(AsRun.OVERRIDE, 90_000, 120_000, 1); unconfirmed.confirmed = false; run.rows.add(unconfirmed); + run.rows.get(0).uhWire = 6; run.rows.get(0).lh = 4; run.rows.get(2).uhWire = 6; run.rows.get(2).lh = 4; run.rows.get(3).uhWire = 31; + GrowthTrackSession.Draft d = GrowthTrackSession.draft(session("mixed"), run); assertEquals(2, d.parts.size()); + JSONObject p = d.payload(); assertEquals("milking", p.getJSONArray("sets").getJSONObject(0).getString("method")); assertEquals(50, p.getJSONArray("sets").getJSONObject(0).getInt("duration_seconds")); + assertEquals(30, p.getJSONArray("sets").getJSONObject(1).getInt("duration_seconds")); assertEquals("rip", p.getJSONArray("sets").getJSONObject(1).getString("method")); assertFalse(p.getJSONArray("sets").getJSONObject(0).has("pressure_inhg")); + } + @Test void inconsistentRecordingIsRejectedRatherThanInventingDurations() { + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 60_000, 0)); run.rows.add(row(AsRun.PLAN, 50_000, 80_000, 1)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("overlap"), run)); + } + @Test void sendRequiresExplicitApprovalAndNoTrafficOnStartup() throws Exception { + Fixture f = new Fixture(false); f.client.sendApproved(); assertTrue(f.http.requests.isEmpty()); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); assertTrue(restarted.snapshot().connected); assertTrue(f.http.requests.isEmpty()); + } + @Test void durableApprovedSnapshotsAreDetachedFromMutableReviewObjects() throws Exception { + Fixture f = new Fixture(false); JSONObject p = payload("stable"); f.client.approve(Arrays.asList(p)); p.put("routine_name", "changed later"); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); f.http.responses.add(ok(1, 0, new JSONArray())); restarted.resumeApproved(); + assertEquals("Fictional fixture", new JSONObject(f.http.requests.get(0).body).getJSONArray("sessions").getJSONObject(0).getString("routine_name")); + } + @Test void duplicateSendUsesExactlyTheSameExternalId() throws Exception { + Fixture f = new Fixture(false); f.approve("stable"); f.http.responses.add(ok(1, 0, new JSONArray())); f.client.sendApproved(); + f.approve("stable"); f.http.responses.add(ok(0, 1, new JSONArray())); f.client.sendApproved(); + assertEquals(f.http.requests.get(0).body, f.http.requests.get(1).body); assertEquals(0, f.client.snapshot().queued); assertTrue(f.client.receipt("op_stable").startsWith("Received")); + } + @Test void offlineRetrySurvivesRestartAndHonorsBackoff() throws Exception { + Fixture f = new Fixture(false); f.approve("offline"); f.http.responses.add(new IOException()); assertThrows(IOException.class, () -> f.client.sendApproved()); + String first = f.http.requests.get(0).body; assertEquals(1, f.client.snapshot().queued); assertEquals(f.clock.time + 5000, f.client.snapshot().retryAt); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); assertThrows(IOException.class, () -> restarted.resumeApproved()); assertEquals(1, f.http.requests.size()); + f.clock.time += 5001; f.http.responses.add(ok(0, 1, new JSONArray())); restarted.resumeApproved(); assertEquals(first, f.http.requests.get(1).body); assertEquals(0, restarted.snapshot().queued); + } + @Test void repeatedServerFailuresBackOffAndHonorRetryAfter() throws Exception { + Fixture f = new Fixture(false); f.approve("server"); f.http.responses.add(new GrowthTrackClient.Response(503, "", "30")); assertThrows(IOException.class, () -> f.client.sendApproved()); assertEquals(f.clock.time + 30_000, f.client.snapshot().retryAt); + f.clock.time += 30_001; f.http.responses.add(new GrowthTrackClient.Response(503, "")); assertThrows(IOException.class, () -> f.client.resumeApproved()); assertEquals(f.clock.time + 10_000, f.client.snapshot().retryAt); + } + @Test void unauthorizedRefreshesOnlyOnceAndRotatesBeforeResendingTheSameBatch() throws Exception { + Fixture f = new Fixture(false); f.approve("auth"); f.http.responses.add(new GrowthTrackClient.Response(401, "{}")); f.http.responses.add(token(R2, false)); f.http.responses.add(ok(1, 0, new JSONArray())); f.client.sendApproved(); + assertEquals(3, f.http.requests.size()); assertEquals("/oauth-token", f.http.requests.get(1).path); assertEquals(f.http.requests.get(0).body, f.http.requests.get(2).body); assertEquals(R2, f.storage.saved.getJSONObject("tokens").getString("refresh_token")); + assertFalse(f.storage.saved.optBoolean("refreshing")); assertFalse(f.http.requests.get(0).headers.containsKey("x-partner-key")); + } + @Test void aSecondUnauthorizedResponseDisconnectsWithoutAnInfiniteRefreshLoop() throws Exception { + Fixture f = new Fixture(false); f.approve("auth"); f.http.responses.add(new GrowthTrackClient.Response(401, "{}")); f.http.responses.add(token(R2, false)); f.http.responses.add(new GrowthTrackClient.Response(401, "{}")); + assertThrows(IOException.class, () -> f.client.sendApproved()); assertEquals(3, f.http.requests.size()); assertFalse(f.client.snapshot().connected); assertEquals(0, f.client.snapshot().queued); + } + @Test void ambiguousRotatingRefreshRequiresRelinkRatherThanReplaying() throws Exception { + Fixture f = new Fixture(false); f.approve("refresh"); f.clock.time += 900_000; f.http.responses.add(new IOException()); assertThrows(IOException.class, () -> f.client.sendApproved()); + assertFalse(f.client.snapshot().connected); assertEquals(0, f.client.snapshot().queued); assertEquals(1, f.http.requests.size()); assertThrows(IOException.class, () -> f.client.resumeApproved()); assertEquals(1, f.http.requests.size()); + } + @Test void restartDuringRefreshDropsTheOldCredentialAndQueuedApprovals() throws Exception { + Fixture f = new Fixture(false); f.approve("refresh"); f.storage.saved.put("refreshing", true); + GrowthTrackClient restarted = new GrowthTrackClient(f.storage, f.http, f.clock); assertFalse(restarted.snapshot().connected); assertEquals(0, restarted.snapshot().queued); assertTrue(f.http.requests.isEmpty()); + } + @Test void partialResultsRecordOnlyKnownCompleteOutcomesAndRetainTransientFailures() throws Exception { + Fixture f = new Fixture(false); f.approve("accepted", "duplicate", "temporary"); + f.http.responses.add(ok(1, 1, new JSONArray().put(new JSONObject().put("external_session_id", "op_temporary").put("reason", "Insert failed")))); f.client.sendApproved(); + assertEquals(1, f.client.snapshot().queued); assertTrue(f.client.receipt("op_accepted").startsWith("Received")); assertEquals("", f.client.receipt("op_temporary")); + } + @Test void permanentPerSessionRejectionIsVisibleAndNotRetriedAutomatically() throws Exception { + Fixture f = new Fixture(false); f.approve("bad"); f.http.responses.add(ok(0, 0, new JSONArray().put(new JSONObject().put("external_session_id", "op_bad").put("reason", "Unknown method")))); f.client.sendApproved(); + assertEquals(0, f.client.snapshot().queued); assertTrue(f.client.receipt("op_bad").startsWith("Needs review")); + } + @Test void incompleteOrForeignResultDoesNotMarkAnythingSent() throws Exception { + Fixture f = new Fixture(false); f.approve("mine"); f.http.responses.add(ok(0, 0, new JSONArray().put(new JSONObject().put("external_session_id", "op_someone_else").put("reason", "bad")))); + assertThrows(IOException.class, () -> f.client.sendApproved()); assertEquals(1, f.client.snapshot().queued); assertEquals("", f.client.receipt("op_mine")); + } + @Test void oversizeResponseSplitsWithoutChangingStableIds() throws Exception { + Fixture f = new Fixture(false); f.approve("one", "two"); f.http.responses.add(new GrowthTrackClient.Response(413, "{}")); f.http.responses.add(ok(1, 0, new JSONArray())); f.http.responses.add(ok(1, 0, new JSONArray())); f.client.sendApproved(); + assertEquals(2, new JSONObject(f.http.requests.get(0).body).getJSONArray("sessions").length()); assertEquals(1, new JSONObject(f.http.requests.get(1).body).getJSONArray("sessions").length()); assertEquals(0, f.client.snapshot().queued); + } + @Test void boundedBatchingCountsUtf8BytesAndNeverExceedsLimits() throws Exception { + Fixture f = new Fixture(false); List list = new ArrayList<>(); + for (int i = 0; i < 105; i++) { JSONObject s = payload("batch" + i); s.put("routine_name", "å".repeat(150)); s.remove("method"); s.put("total_duration_seconds", 1000); JSONArray sets = new JSONArray(); for (int j = 0; j < 500; j++) sets.put(new JSONObject().put("set_index", j + 1).put("method", "rapid_interval_vacuum_extending").put("duration_seconds", 1)); s.put("sets", sets); list.add(s); } + f.client.approve(list); f.http.acceptAll = true; f.client.sendApproved(); assertTrue(f.http.requests.size() > 2); + for (Request r : f.http.requests) { assertTrue(r.body.getBytes(StandardCharsets.UTF_8).length <= GrowthTrackProtocol.MAX_BYTES); assertTrue(new JSONObject(r.body).getJSONArray("sessions").length() <= GrowthTrackProtocol.BATCH_SIZE); } + assertEquals(0, f.client.snapshot().queued); + } + @Test void approvalRejectsPrivateFieldsBeforeAnyNetworkRequest() throws Exception { + Fixture f = new Fixture(false); JSONObject s = payload("private"); s.put("note", "must never escape"); assertThrows(IOException.class, () -> f.client.approve(Arrays.asList(s))); assertTrue(f.http.requests.isEmpty()); + } + @Test void cancelAndDisconnectStopFutureRequestsAndClearAuthority() throws Exception { + Fixture f = new Fixture(false); f.approve("cancel"); f.http.beforeReturn = () -> f.client.cancelRequests(); f.http.responses.add(ok(1, 0, new JSONArray())); + assertThrows(IOException.class, () -> f.client.sendApproved()); f.client.cancelApproved(); assertEquals(0, f.client.snapshot().queued); assertEquals("", f.client.receipt("op_cancel")); + f.client.disconnect(); assertFalse(f.client.snapshot().connected); assertFalse(f.storage.saved.has("tokens")); assertThrows(IOException.class, () -> f.client.resumeApproved()); assertEquals(1, f.http.requests.size()); + } + @Test void relinkingCannotCarryAnOldAccountsApprovedPayloadsOrReceipts() throws Exception { + Fixture f = new Fixture(false); f.approve("previous"); f.client.begin(true); assertFalse(f.client.snapshot().connected); assertEquals(0, f.client.snapshot().queued); assertTrue(f.client.snapshot().pending); assertEquals("", f.client.receipt("op_previous")); + } + @Test void storageFailureFailsClosedBeforeUpload() throws Exception { + Fixture f = new Fixture(false); f.storage.fail = true; assertThrows(IOException.class, () -> f.approve("storage")); assertThrows(IOException.class, () -> f.client.resumeApproved()); assertFalse(f.client.snapshot().connected); assertTrue(f.http.requests.isEmpty()); + } + @Test void serializedSendsCannotRefreshInParallelOrDuplicateTheQueue() throws Exception { + Fixture f = new Fixture(false); f.approve("parallel"); f.clock.time += 900_000; f.http.responses.add(token(R2, false)); f.http.responses.add(ok(1, 0, new JSONArray())); + var executor = Executors.newFixedThreadPool(2); + try { List> calls = Arrays.asList(() -> { f.client.sendApproved(); return null; }, () -> { f.client.sendApproved(); return null; }); for (var future : executor.invokeAll(calls)) future.get(5, TimeUnit.SECONDS); } + finally { executor.shutdownNow(); } + assertEquals(2, f.http.requests.size()); assertEquals(0, f.client.snapshot().queued); + } + @Test void routinePermissionIsSeparateAndUnsupportedFormatsAreRejected() throws Exception { + Fixture without = new Fixture(false); assertThrows(IOException.class, () -> without.client.routines(null)); assertTrue(without.http.requests.isEmpty()); + Fixture with = new Fixture(true); with.http.responses.add(new GrowthTrackClient.Response(200, "{\"format\":\"pet-routine\",\"schema_version\":1,\"routines\":[]}")); assertThrows(IOException.class, () -> with.client.routines(null)); + with.http.responses.add(new GrowthTrackClient.Response(200, "{\"format\":\"gt-routine\",\"schema_version\":2,\"routines\":[]}")); assertThrows(IOException.class, () -> with.client.routines(null)); + with.http.responses.add(new GrowthTrackClient.Response(200, "{\"format\":\"gt-routine\",\"schema_version\":1,\"routines\":[],\"new_optional_field\":true}")); assertEquals(0, with.client.routines(null).getJSONArray("routines").length()); + } + + @Test void fixedHoldRulesHonorEveryInclusiveBoundaryBeforeRounding() { + assertEquals("milking", GrowthTrackSession.methodForHoldMillis(0.1)); + assertEquals("milking", GrowthTrackSession.methodForHoldMillis(5999.9)); + assertEquals("milking", GrowthTrackSession.methodForHoldMillis(6000)); + assertEquals("rip", GrowthTrackSession.methodForHoldMillis(6000.1)); + assertEquals("rip", GrowthTrackSession.methodForHoldMillis(30000)); + assertEquals("interval_pumping", GrowthTrackSession.methodForHoldMillis(30000.1)); + assertEquals("interval_pumping", GrowthTrackSession.methodForHoldMillis(120000)); + assertEquals("static_pumping", GrowthTrackSession.methodForHoldMillis(120000.1)); + for (double invalid : new double[]{0, -1, Double.NaN, Double.POSITIVE_INFINITY}) + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.methodForHoldMillis(invalid)); + } + @Test void actualAcknowledgedHoldDrivesLabelNotRequestedHoldSetLengthOrRelease() throws Exception { + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 120_000, 0); + r.uhWire = 6; r.uhReq = 200; r.lh = 80; run.rows.add(r); + JSONObject p = GrowthTrackSession.draft(session("wire"), run).payload(); + assertEquals("milking", p.getJSONArray("sets").getJSONObject(0).getString("method")); + assertEquals(120, p.getInt("total_duration_seconds")); + assertEquals(120, p.getJSONArray("sets").getJSONObject(0).getInt("duration_seconds")); + assertEquals(60, p.getInt("time_under_tension_seconds")); + } + @Test void liveHoldChangesWithinOneSetRemainMixedWithoutSplittingTheSitting() throws Exception { + Model.Sess s = session("varying"); s.durSec = 1000; s.recordedEndMs = s.recordedStartMs + 1000000L; + AsRun run = new AsRun(); int[] holds = {6, 7, 30, 31, 120, 121}; + for (int i = 0; i < holds.length; i++) { AsRun.Row r = row(AsRun.OVERRIDE, i * 150_000L, i * 150_000L + (holds[i] + 5) * 1000L, 0); r.uhWire = holds[i]; run.rows.add(r); } + JSONObject p = GrowthTrackSession.draft(s, run).payload(); + assertEquals("op_varying", p.getString("external_session_id")); assertEquals(6, p.getJSONArray("sets").length()); + String[] expected = {"milking", "rip", "rip", "interval_pumping", "interval_pumping", "static_pumping"}; + for (int i = 0; i < expected.length; i++) assertEquals(expected[i], p.getJSONArray("sets").getJSONObject(i).getString("method")); + } + @Test void frozenLengthTrackOverridesEveryHoldIncludingUnknownHoldAndSurvivesRoutineDeletion() throws Exception { + Model.Sess s = session("length"); s.durSec = 1000; s.recordedEndMs = s.recordedStartMs + 1000000L; + AsRun run = new AsRun(); run.snap = new Model.AsRunSnapshot(); run.snap.trainerTrack = Plan.TRACK_LENGTH; + int[] holds = {0, 6, 30, 120, 255}; + for (int i = 0; i < holds.length; i++) { AsRun.Row r = row(AsRun.PLAN, i * 150_000L, (i + 1) * 150_000L, i); r.uhWire = holds[i]; run.rows.add(r); } + run.snap = Model.AsRunSnapshot.fromJson(run.snap.toJson()); + JSONObject p = GrowthTrackSession.draft(s, run, new Model()).payload(); + for (int i = 0; i < p.getJSONArray("sets").length(); i++) assertEquals("length_pumping", p.getJSONArray("sets").getJSONObject(i).getString("method")); + s.asRunSnapshot = run.snap; JSONObject withoutRows = GrowthTrackSession.draft(s, null).payload(); + assertEquals("length_pumping", withoutRows.getString("method")); assertFalse(withoutRows.has("sets")); + } + @Test void snapshotTrackMigrationPreservesUnknownVersusKnownUnmarkedAndRecordedLength() throws Exception { + Model.AsRunSnapshot old = Model.AsRunSnapshot.fromJson(new JSONObject("{\"rid\":\"old\",\"stages\":[]}")); + assertEquals(-1, old.trainerTrack); assertFalse(old.toJson().has("trainerTrack")); + Model.Routine routine = new Model.Routine(); routine.id = "new"; routine.trainerTrack = Plan.TRACK_LENGTH; + Model.AsRunSnapshot frozen = Model.AsRunSnapshot.of(routine, new Model()); routine.trainerTrack = 0; + assertEquals(Plan.TRACK_LENGTH, Model.AsRunSnapshot.fromJson(frozen.toJson()).trainerTrack); + assertEquals(0, Model.AsRunSnapshot.fromJson(Model.AsRunSnapshot.of(routine, new Model()).toJson()).trainerTrack); + } + @Test void legacyStructuredLengthMarkerRequiresAnUnchangedMatchingSnapshot() throws Exception { + Model model = new Model(); Model.Routine routine = new Model.Routine(); routine.id = "r"; routine.trainerTrack = Plan.TRACK_LENGTH; model.routines.add(routine); + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120_000, 0)); run.snap = Model.AsRunSnapshot.of(routine, model); run.snap.trainerTrack = -1; + Model.Sess s = session("legacy"); s.routineId = "r"; + assertEquals("length_pumping", GrowthTrackSession.draft(s, run, model).parts.get(0).method); + Model.Stage added = new Model.Stage(); added.name = "Later edit"; routine.stages.add(added); + assertEquals("rip", GrowthTrackSession.draft(s, run, model).parts.get(0).method); + } + @Test void freeTextNamesAndCountedTrackDoNotInventAPhysicalLengthDesignation() { + Model.Sess s = session("name"); s.routineName = "Length pumping"; s.countedTrack = Plan.TRACK_LENGTH; + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120_000, 0)); + assertEquals("rip", GrowthTrackSession.draft(s, run).parts.get(0).method); + } + @Test void noRecordingZeroHoldAndNoConfirmedWorkCannotInventATechnique() { + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("old"), null)); + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 120_000, 0); r.uhWire = 0; run.rows.add(r); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("zero"), run)); + r.uhWire = 6; r.confirmed = false; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("unconfirmed"), run)); + r.confirmed = true; r.kind = AsRun.REST; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("rest"), run)); + } + @Test void subsecondWorkKeepsItsMethodWithoutFabricatingASecond() throws Exception { + Model.Sess s = session("fraction"); s.durSec = 1; s.recordedEndMs = s.recordedStartMs + 1000L; s.netTupSec = null; + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 600, 0); r.uhWire = 6; run.rows.add(r); + JSONObject p = GrowthTrackSession.draft(s, run).payload(); + assertTrue(p.getJSONArray("sets").getJSONObject(0).isNull("duration_seconds")); + Fixture f = new Fixture(false); f.client.approve(Arrays.asList(p)); assertEquals(1, f.client.snapshot().queued); + s.durSec = 0; assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, run)); + } + @Test void grossSealedTimeIsNeverSentAsUnderTensionWhenNetWasNotRecorded() throws Exception { + Model.Sess s = session("gross"); s.netTupSec = null; s.grossTupSec = 120.0; + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120_000, 0)); + assertFalse(GrowthTrackSession.draft(s, run).payload().has("time_under_tension_seconds")); + } + @Test void transientPerItemFailuresUseIncreasingBackoffAcrossRetries() throws Exception { + Fixture f = new Fixture(false); f.approve("temporary"); + JSONArray rejected = new JSONArray().put(new JSONObject().put("external_session_id", "op_temporary").put("reason", "Insert failed")); + f.http.responses.add(ok(0, 0, rejected)); f.client.sendApproved(); assertEquals(f.clock.time + 5000, f.client.snapshot().retryAt); + f.clock.time += 5001; f.http.responses.add(ok(0, 0, rejected)); f.client.resumeApproved(); assertEquals(f.clock.time + 10000, f.client.snapshot().retryAt); + } + @Test void invalidTypedPayloadFieldsFailBeforeNetworkAndDoNotCreateApproval() throws Exception { + for (String field : Arrays.asList("total_duration_seconds", "time_under_tension_seconds", "pressure_peak_inhg", "started_at", "completed_at", "routine_name")) { + Fixture f = new Fixture(false); JSONObject p = payload("bad"); + if (field.endsWith("seconds")) p.put(field, 1.5); else if (field.equals("pressure_peak_inhg")) p.put(field, 30); else if (field.equals("routine_name")) p.put(field, "x".repeat(201)); else p.put(field, "invalid"); + assertThrows(IOException.class, () -> f.client.approve(Arrays.asList(p)), field); + assertTrue(f.http.requests.isEmpty()); assertEquals(0, f.client.snapshot().queued); + } + Fixture f = new Fixture(false); JSONObject p = payload("index"); p.getJSONArray("sets").getJSONObject(0).put("set_index", 0); + assertThrows(IOException.class, () -> f.client.approve(Arrays.asList(p))); assertTrue(f.http.requests.isEmpty()); + } + @Test void goneGrantClearsAuthorityAndForbiddenPermissionPreservesApprovalForExplicitRelink() throws Exception { + Fixture gone = new Fixture(false); gone.approve("gone"); gone.http.responses.add(new GrowthTrackClient.Response(410, "{}")); + assertThrows(IOException.class, () -> gone.client.sendApproved()); assertFalse(gone.client.snapshot().connected); assertEquals(0, gone.client.snapshot().queued); + Fixture denied = new Fixture(false); denied.approve("denied"); denied.http.responses.add(new GrowthTrackClient.Response(403, "{}")); + assertThrows(IOException.class, () -> denied.client.sendApproved()); assertEquals(1, denied.client.snapshot().queued); assertEquals(1, denied.http.requests.size()); + } + @Test void supportedRoutineDetailUsesReadScopeAndDoesNotChangeSessionQueue() throws Exception { + Fixture f = new Fixture(true); String id = "00000000-0000-4000-8000-000000000001"; + JSONObject detail = new JSONObject().put("format", "gt-routine").put("schema_version", 1) + .put("routine", new JSONObject().put("external_id", id).put("steps", new JSONArray())); + f.http.responses.add(new GrowthTrackClient.Response(200, detail.toString())); assertEquals(id, f.client.routines(id).getJSONObject("routine").getString("external_id")); + assertEquals("GET", f.http.requests.get(0).method); assertNull(f.http.requests.get(0).body); + assertEquals("no-store", f.http.requests.get(0).headers.get("Cache-Control")); assertEquals(0, f.client.snapshot().queued); + assertThrows(IOException.class, () -> f.client.routines("../../tokens")); assertEquals(1, f.http.requests.size()); + } + @Test void stoppedUpperHoldUsesClippedDurationAndNeverItsLongerSetting() { + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 6000, 0); r.uhWire = 121; run.rows.add(r); + GrowthTrackSession.Part p = GrowthTrackSession.draft(session("clipped"), run).parts.get(0); + assertEquals("milking", p.method); assertEquals(6000, p.holdMillis); + r.t1 = 30001; assertEquals("interval_pumping", GrowthTrackSession.draft(session("clipped"), run).parts.get(0).method); + } + @Test void completedCyclesAndInterruptedLastCycleCanHaveDifferentMethods() { + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 26000, 0); r.uhWire = 20; r.lh = 5; run.rows.add(r); + GrowthTrackSession.Draft d = GrowthTrackSession.draft(session("cycles"), run); + assertEquals(2, d.parts.size()); assertEquals("rip", d.parts.get(0).method); assertEquals(25000, d.parts.get(0).recordedMillis); + assertEquals("milking", d.parts.get(1).method); assertEquals(1000, d.parts.get(1).holdMillis); + } + @Test void zeroReleaseIsOneContinuousRecordedHold() { + Model.Sess s = session("continuous"); s.durSec = 150; s.recordedEndMs = s.recordedStartMs + 150000L; + AsRun run = new AsRun(); AsRun.Row r = row(AsRun.PLAN, 0, 121000, 0); r.uhWire = 6; r.lh = 0; run.rows.add(r); + assertEquals("static_pumping", GrowthTrackSession.draft(s, run).parts.get(0).method); + } + static AsRun stitch(boolean frozen) { + AsRun run = new AsRun(); + AsRun.Row first = row(AsRun.PLAN, 0, 255000, 0); first.uhWire = 255; first.uhReq = 255; first.lh = 1; first.lo = first.up - 1; + AsRun.Row last = row(AsRun.PLAN, 255000, 360000, 0); last.ordinal = 1; last.uhWire = 45; last.uhReq = 45; last.lh = 60; + if (frozen) { first.cyclePart = 1; last.cyclePart = 0; } + run.rows.add(first); run.rows.add(last); return run; + } + @Test void verifiedStitchKeepsOneLongHoldWithReleaseExcludedFromLabel() throws Exception { + Model.Sess s = session("stitch"); s.durSec = 360; s.recordedEndMs = s.recordedStartMs + 360000L; + GrowthTrackSession.Draft d = GrowthTrackSession.draft(s, stitch(true)); + assertEquals(1, d.parts.size()); assertEquals(300000, d.parts.get(0).holdMillis); assertEquals("static_pumping", d.parts.get(0).method); + assertEquals(360, d.payload().getJSONArray("sets").getJSONObject(0).getInt("duration_seconds")); + } + @Test void oldSnapshotCanProveStitchButAnUnknownBoundaryCannot() { + Model.Sess s = session("oldStitch"); s.durSec = 360; s.recordedEndMs = s.recordedStartMs + 360000L; AsRun run = stitch(false); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, run)); + Model model = new Model(); Model.Set set = Model.Set.fixed("set0", "old long hold", 20, 0, 300, 60, 50, 360); model.sets.add(set); + Model.Routine routine = new Model.Routine(); Model.Stage stage = new Model.Stage(); stage.setIds.add(set.id); routine.stages.add(stage); + run.snap = Model.AsRunSnapshot.of(routine, model); + assertEquals(1, GrowthTrackSession.draft(s, run).parts.size()); assertEquals(300000, GrowthTrackSession.draft(s, run).parts.get(0).holdMillis); + } + @Test void changedOrUnconfirmedStitchFailsClosedAndStoppedChunkIsClipped() { + Model.Sess s = session("badStitch"); s.durSec = 360; s.recordedEndMs = s.recordedStartMs + 360000L; AsRun run = stitch(true); run.rows.get(1).confirmed = false; final AsRun unconfirmed = run; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, unconfirmed)); + run = stitch(true); run.rows.get(1).up = 21; final AsRun changed = run; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, changed)); + run = stitch(true); run.rows.remove(1); run.rows.get(0).t1 = 6000; + assertEquals("milking", GrowthTrackSession.draft(s, run).parts.get(0).method); + } + @Test void pausedOrLostPhaseCannotBeInventedButStructuredLengthStillOverrides() { + for (int kind : new int[]{AsRun.HOLD, AsRun.LOST}) { + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 20000, 0)); run.rows.add(row(kind, 20000, 30000, 0)); run.rows.add(row(AsRun.PLAN, 30000, 50000, 0)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(session("gap"), run)); + run.snap = new Model.AsRunSnapshot(); run.snap.trainerTrack = Plan.TRACK_LENGTH; + for (GrowthTrackSession.Part p : GrowthTrackSession.draft(session("gap"), run).parts) assertEquals("length_pumping", p.method); + } + } + @Test void frozenStitchMarkerRoundTripsAndLegacyRemainsUnknown() throws Exception { + AsRun.Row old = AsRun.Row.fromJson(new JSONObject()); assertEquals(-1, old.cyclePart); assertFalse(old.toJson().has("cyclePart")); + AsRun run = new AsRun(); Model.Preset preset = new Model.Preset(); preset.cyclePart = true; + AsRun.Row now = run.open(AsRun.PLAN, 0, preset, 20, 20, 19, 255, 255, 1, 50); + assertEquals(1, AsRun.Row.fromJson(now.toJson()).cyclePart); + preset.cyclePart = false; assertEquals(0, run.open(AsRun.PLAN, 1000, preset, 20, 20, 0, 6, 6, 4, 50).cyclePart); + } + + @Test void staleReviewCannotBeApprovedAfterAnAccountRelink() throws Exception { + Fixture f = new Fixture(false); String old = f.client.snapshot().connectionId; f.approve("old"); + f.client.begin(false); assertEquals(0, f.client.snapshot().queued); + String state = f.storage.saved.getJSONObject("pending").getString("state"); f.http.responses.add(token(R2, false)); + f.client.callback(GrowthTrackProtocol.REDIRECT + "?code=" + CODE + "&state=" + state); f.http.requests.clear(); + assertNotEquals(old, f.client.snapshot().connectionId); + assertThrows(IOException.class, () -> f.client.approve(Arrays.asList(payload("stale")), old)); + assertEquals(0, f.client.snapshot().queued); assertTrue(f.http.requests.isEmpty()); + f.client.approve(Arrays.asList(payload("fresh")), f.client.snapshot().connectionId); assertEquals(1, f.client.snapshot().queued); + } + @Test void restoredQueueWithDifferentGrantIdentityIsDiscardedWithoutTraffic() throws Exception { + Fixture f = new Fixture(false); f.approve("old"); f.storage.saved.put("approved_connection_id", "another-grant"); + GrowthTrackClient restored = new GrowthTrackClient(f.storage, f.http, f.clock); + assertTrue(restored.snapshot().connected); assertEquals(0, restored.snapshot().queued); assertTrue(f.http.requests.isEmpty()); + } + @Test void refreshRetainsGrantIdentityAndApprovedSnapshotsForTheSameAccount() throws Exception { + Fixture f = new Fixture(false); String id = f.client.snapshot().connectionId; f.approve("refreshIdentity"); + f.clock.time += 901000; f.http.responses.add(token(R2, false)); f.http.responses.add(ok(1, 0, new JSONArray())); f.client.sendApproved(); + assertEquals(id, f.client.snapshot().connectionId); assertEquals(0, f.client.snapshot().queued); assertEquals(2, f.http.requests.size()); + } + + @Test void legacyTimestampMeaningAndRejoinedBoundsAreNeverGuessedEvenForLength() { + Model.Sess s = session("unknownTime"); s.recordedStartMs = 0; s.recordedEndMs = 0; + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120000, 0)); run.snap = new Model.AsRunSnapshot(); run.snap.trainerTrack = Plan.TRACK_LENGTH; + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, run)); + assertThrows(IllegalArgumentException.class, () -> GrowthTrackSession.draft(s, null)); + } + @Test void explicitlyRecordedWallClockBoundsTakePrecedenceOverLegacyKeyAndElapsed() throws Exception { + Model.Sess s = session("wallclock"); s.ts += 999000; s.recordedEndMs += 60000; + AsRun run = new AsRun(); run.rows.add(row(AsRun.PLAN, 0, 120000, 0)); + JSONObject payload = GrowthTrackSession.draft(s, run).payload(); + assertEquals("2023-11-14T22:13:20.000Z", payload.getString("started_at")); + assertEquals("2023-11-14T22:16:20.000Z", payload.getString("completed_at")); assertEquals(120, payload.getInt("total_duration_seconds")); + Model.Sess restored = Model.Sess.fromJson(s.toJson()); assertEquals(s.recordedStartMs, restored.recordedStartMs); assertEquals(s.recordedEndMs, restored.recordedEndMs); + } + +} diff --git a/core/src/test/java/org/openpump/GrowthTrackOnboardingTest.java b/core/src/test/java/org/openpump/GrowthTrackOnboardingTest.java new file mode 100644 index 0000000..9ff2251 --- /dev/null +++ b/core/src/test/java/org/openpump/GrowthTrackOnboardingTest.java @@ -0,0 +1,37 @@ +package org.openpump; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class GrowthTrackOnboardingTest { + @Test void freshSetupCanOfferWithoutGrantingUploadingOrChangingFirstRun() { + Model m = Model.seed(); int first = m.firstRun; + assertTrue(GrowthTrackOnboarding.shouldOffer(m, false, false)); + GrowthTrackOnboarding.presented(m); assertEquals(first, m.firstRun); assertTrue(m.sessLog.all.isEmpty()); + assertFalse(GrowthTrackOnboarding.shouldOffer(m, false, false)); + } + @Test void skipBackCancelAndRepeatedLaunchDoNotRepeatOffer() throws Exception { + for (String exit : new String[]{"skip", "back", "cancel", "close", "connect"}) { + Model m = Model.seed(); GrowthTrackOnboarding.presented(m); + Model restored = Model.fromJson(m.toJson()); + assertFalse(GrowthTrackOnboarding.shouldOffer(restored, false, false), exit); + assertEquals(FirstRun.NOT_STARTED, restored.firstRun); + } + } + @Test void existingUsersAndARealSessionAreNeverInterrupted() throws Exception { + assertFalse(GrowthTrackOnboarding.shouldOffer(Model.fromJson("{\"sets\":[],\"routines\":[]}"), false, false)); + Model m = Model.seed(); Model.Sess s = new Model.Sess(); s.sim = true; m.sessLog.all.add(s); + assertTrue(GrowthTrackOnboarding.shouldOffer(m, false, false)); + s.sim = false; assertFalse(GrowthTrackOnboarding.shouldOffer(m, false, false)); + } + @Test void activeRunOrUnreadableStoreDefersOfferWithoutConsumingIt() { + Model m = Model.seed(); assertFalse(GrowthTrackOnboarding.shouldOffer(m, true, false)); + assertFalse(GrowthTrackOnboarding.shouldOffer(m, false, true)); assertFalse(m.growthTrackOfferSeen); + } + @Test void completingSetupAfterSkipStillUsesExistingStarterGateAndMigrationDefault() throws Exception { + Model m = Model.seed(); GrowthTrackOnboarding.presented(m); FirstRun.finish(m); + assertEquals(FirstRun.FINISH_STARTER, FirstRun.finishAction("starter", true, false)); + assertEquals(FirstRun.DONE, m.firstRun); assertTrue(Model.fromJson("{\"sets\":[],\"routines\":[]}").growthTrackOfferSeen); + assertTrue(Model.fromJson(m.toJson()).growthTrackOfferSeen); + } +} diff --git a/docs/growthtrack-developer-handoff.md b/docs/growthtrack-developer-handoff.md new file mode 100644 index 0000000..1256d43 --- /dev/null +++ b/docs/growthtrack-developer-handoff.md @@ -0,0 +1,136 @@ +# GrowthTrack integration — developer handoff + +Connecting a user's own GrowthTrack account now authorizes **automatic transfer of every new real finished session** recorded while connected. Disconnected sessions send nothing. There is no per-session picker, review, Send button or normal selective opt-in. Mixed techniques remain **one GrowthTrack session** with granular set/timing detail. Invalid, simulated or unrepresentable recordings have explicit status rather than a silent omission. + +**Android compilation, lint and device/platform QA remain required before release.** JVM tests and source checks have passed. This work has not installed or run the Android app, made a live OAuth grant, transmitted real sessions or operated pump hardware. + +## Checkout and scope + +- Fork: . +- Hemmaservern checkout: `F:\Codex\OpenPump-App`, branch `codex/growthtrack-integration`, baseline commit `1dddec436e552b0669e69427e9d054a71598d859`. The implementation is carried by the feature branch. +- Origin: `https://github.com/kwikmn/OpenPump-App.git`; upstream: `https://github.com/OpenByte-source/OpenPump-App.git`. +- Publication target: [personal-fork feature branch](https://github.com/kwikmn/OpenPump-App/tree/codex/growthtrack-integration). Publishing this branch does not merge main, deploy the site or send the developer message; no upstream PR was requested. +- GrowthTrack source was inspected read-only and remains unchanged. Its advanced routine builder is a future task, not implemented here. +- Pump commands, pressure ceilings, START, stop/vent and the session engine are unchanged. Filing adds data-only timestamp/connection markers and dispatches background work. GrowthTrack UI refuses browser/keyguard/navigation handoffs while the pump owner is unsafe or RunService is running. Neither data screens nor the retry service own a PumpLink. + +## User behavior and consent + +The fresh-install Ready screen offers connection once, with Skip; Settings → Connected apps → GrowthTrack remains available later. Existing models default the offer to already seen. Displaying it consumes the one-time offer, so cancellation, Back, upgrade and relaunch do not nag. Existing setup and START gates remain intact. + +The connection dialog and information screen explain that browser approval enables automatic session sync to the user's own linked account. OAuth approval is still performed by the human in the system browser. Connection never starts hardware. Earlier history and records filed while disconnected are **not silently uploaded on connection**. This branch has no historical-import selection flow; historical policy was unspecified and remains a separate decision. + +Every new filed real recording with representable saved work goes through the same sync path, including stopped sessions with recorded work. Simulations, missing stable identifiers, missing/inconsistent original timestamps, and unsupported timing have visible failure status. There is no technique override. Session summary opens sync status, not a send form. Retry is an optional recovery control; ordinary completed sessions require no action. A permission error asks for reconnect instead of repeatedly attempting unauthorized requests. + +Local disconnect stops future requests, cancels the retry job and erases encrypted connection state and its Keystore key. Erase-all OpenPump data also resets the connection. A request already in flight may have committed; already received sessions remain in GrowthTrack. Reconnect changes grant identity and discards previous-account pending work and receipts. No profile/email scope exists in the inspected contract, so OpenPump does not invent a verified local account name. The browser identifies the approved account. + +## Architecture and files + +| Area | Responsibility | +|---|---| +| `GrowthTrackProtocol` | Fixed public registration, S256 PKCE, random state/verifier, strict callback, stable external IDs. | +| `GrowthTrackClient` | Serial OAuth/refresh, allowlisted completion snapshots, same-grant encrypted queue, receipts, bounded batches, retry/backoff and partial results. | +| `GrowthTrackSession`, `GrowthTrackTiming` | Recorded wall-clock/time/pressure mapping, fixed techniques, confirmed command-phase clipping and verified stitch joins. | +| `GrowthTrackOnboarding`, `SetupScreen` | One-time invitation, Skip and clear automatic-sync consent. | +| `GrowthTrackStore` | Keystore AES-GCM, AtomicFile and no-backup storage; no plaintext fallback. | +| `GrowthTrackHttp` | Fixed-origin HTTPS, no redirects/cookies/logging; bounded request/response size and timeouts; cancellation generations; assigned Android job network. | +| `GrowthTrackManager`, `GrowthTrackSyncService` | One serial worker; startup/filing recovery; persisted network-constrained JobScheduler retries; targeted job cancellation. | +| `GrowthTrackActivity`, callback activity | Private connection/status/information screens, system browser callback and own-account consent; optional existing read-only library previews. | +| `Model.Sess`, `AsRun`, `SessionActivity`, `Migrate` | Preserve new original timestamps, frozen track/stitch facts, completion grant marker and old-file compatibility. | +| Settings/summary/Store | Later connection, sync-status entry point and erase-all cleanup. | +| `docs/site/index.html`, `assets/growthtrack/` | Compact inline section, three supplied screenshots, implemented roadmap substep 2c. Separate GrowthTrack page removed. | + +## Durable automatic completion and retry + +`SessionActivity.fileSession()` captures the manager's non-secret browser-grant identity in `Sess.growthTrackConnectionId` immediately before its normal model save. It then dispatches sync; no HTTP runs on the pump/UI thread. Startup initializes the connection cache and also resumes pending work. + +The serial worker reads saved records and only admits markers matching the **current exact browser grant**. This marker closes the filing/queue-save crash window without importing unmarked history. It is not a token or personal identity. Empty legacy/disconnected markers and markers from another approval cannot acquire a new account's consent. Restored model data alone cannot restore authority; encrypted tokens are kept outside backup. + +Completion produces an allowlisted detached payload and appends it to the same-grant durable encrypted queue. Queued IDs and finished/failure receipts prevent duplicate completion callbacks from adding another item. Later source edits cannot alter a queued retry payload. Stable external IDs additionally protect server retries after a lost response. The existing JSON `approved` field remains the internal queue name; native production UI no longer invokes manual approval/selection APIs. Connections from the earlier per-send implementation require fresh browser approval before automatic behavior is enabled. + +Offline, 408/429/5xx, malformed results and transient per-item failures retain the same queue and use exponential delay from 5 seconds to 1 hour; numeric Retry-After is bounded to 24 hours. Persisted one-shot Android jobs require any available network and preserve minimum retry time; Android can delay execution under Doze, quotas or force-stop. Reopening OpenPump also resumes eligible pending work. Scheduling failure is visible and pending snapshots remain secure. There is no fixed delivery-time promise. + +Job ID `72041` is dedicated to GrowthTrack. The service is protected by `BIND_JOB_SERVICE`. Main manifest includes INTERNET and ACCESS_NETWORK_STATE; the latter is required for network-constrained jobs on Android 14. Existing RECEIVE_BOOT_COMPLETED supports persisted jobs. API28+ jobs use the network supplied by JobParameters; older supported platforms use ordinary HTTPS. Replacement scheduling is posted after job completion to avoid stopping the job just finished. A stopped queued job does not cancel unrelated OAuth/routine work; active same-job work is cancelled. Reset blocks new work and invalidates transport generations. + +See official [JobInfo network requirements](https://developer.android.com/reference/android/app/job/JobInfo.Builder#setRequiredNetworkType(int)), [JobScheduler replacement semantics](https://developer.android.com/reference/android/app/job/JobScheduler#schedule(android.app.job.JobInfo)) and [JobService lifecycle](https://developer.android.com/reference/android/app/job/JobService). + +Requests contain at most 100 sessions and 1,000,000 UTF-8 bytes. Oversized multi-session 413 batches split; a single unrepresentable record has a visible failure. Valid response counts and unique known rejected IDs must cover every submitted item before any receipt is recorded. Permanent rejections leave failure receipts; transient items alone remain queued. Accepted versus duplicate counts are aggregate, so a per-record receipt accurately says “Received (sent or already present).” No false individual distinction is invented. Missing permission or unexpected permanent HTTP failure pauses automatic retry visibly. Revocation/second401/410 or unsafe credential rotation clears authority and stops sync. + +## Public OAuth registration and security + +- Client: `gtc_bfa5809efbd314f246dd5d0f` (public PKCE; no native secret). +- Exact callback: `gt-openpump://oauth/callback`. +- API origin: `https://rxtqbktyeetmbigbpbdd.supabase.co/functions/v1`. +- Project/account setup: . +- Remote revoke/manage: . +- Required `sessions:write`; optional `routines:read` for existing read-only previews. +- OAuth authorization is GET `/oauth-authorize`; token exchange/refresh is form POST `/oauth-token`; ingestion uses `/partner-ingest`; routine preview uses `/partner-routines`. + +Parent verified the public registration screenshot. No older sandbox password or unrelated client was reused. Code/state are single-use locally. Callback rejects wrong endpoint/port/userinfo/fragment, duplicated/extra fields, wrong state, code plus error, stale/backwards-clock requests and missing required values. Pending lifetime is 15 minutes. Wrong callbacks preserve the legitimate pending request. + +Access tokens last up to 900 seconds. Refresh rotates serially; the new refresh credential is durably saved before further authenticated HTTP. Before code/refresh requests, a durable exchange marker is written. Crash, ambiguous response or failed secure save forces fresh authorization instead of replaying a potentially consumed credential. Cancellation generations stop stale queued work opening HTTP after reset. These behaviors are covered by mocks, not live server/device tests. + +Token/verifier/payload/receipt state is AES-256-GCM encrypted with AndroidKeystore key `org.openpump.growthtrack.v1`, random 12-byte IV, AAD `GT1`, AtomicFile and a 16 MB encrypted-file cap in no-backup storage. Missing/tampered/inaccessible storage blocks access; no plaintext fallback. Tokens do not enter preferences, model JSON, backup/export, logs, clipboard or forwarded Activity Intents. Only the non-secret completion grant marker enters Sess JSON. Release forbids cleartext; debug diagnostic upload remains separate. Secure-window and app-lock whole/settings/history preferences apply to connection screens and dialogs. Background automatic sync is the already approved connection behavior and does not navigate to UI or unlock the device. + +GT exposes no native token-revocation endpoint in the inspected contract. Device disconnect cannot claim remote revocation; the UI guides the user to Connected apps. + +## Mapping and material limits + +| Positive confirmed hold, excluding release/rest | Fixed method | +|---|---| +| Through 6 seconds | `milking` | +| Above 6 through 30 | `rip` | +| Above 30 through 120 | `interval_pumping` | +| Above 120 | `static_pumping` | +| Structured recorded Length track | `length_pumping`, overriding duration classification | + +Labels use saved acknowledged command holds, clipped to delivered closed-row spans. They are **not measured pressure-plateau timelines**: OP does not preserve every sampled plateau historically. Zero-release rows are one continuous hold. Proven `cyclePart` markers or matching older frozen plans join stitched chunks; 255+45 seconds is one 300-second Static hold when proven. Missing, changed, gapped or unconfirmed stitches and ambiguous paused/lost-link phase histories fail closed. A stopped final chunk is clipped to its delivered span. Release/rest does not affect technique thresholds. Total/set elapsed duration may include recorded releases, and must not be described as continuous time under pressure. + +Structured frozen `trainerTrack` determines Length; no free-text name, picker or guessed category. Old snapshots retain unknown=-1. A verified Length record without detailed rows can use a session-level method without inventing sets, but it still needs reliable timestamps/duration. All mixed methods remain inside one original stable external_session_id; GT's current SetSchema supports per-set method and groups exercises within that sitting. + +Uploaded fields are stable session ID, original started/completed timestamps, routine name, total elapsed seconds, set index/fixed method/recorded duration, observed overall peak (kPa magnitude divided by 3.38639) and saved measured **net** time under pressure when valid. Gross sealed elapsed includes releases and is never substituted. Whole seconds round down; subsecond set duration is null rather than a fabricated second. Unknown/implausible optional telemetry is omitted honestly. No measurements, photos, notes, user/email identity, invented set-pressure average, heat, vibration or traction data is uploaded. + +Old `Sess.ts` meant filing/end in some releases and start in later ones; it cannot safely reconstruct original boundaries. New nonrejoined records preserve `recordedStartMs`/`recordedEndMs`. Old records and rejoined records without all original bounds are unrepresentable. No guessed backfill was added. New unsupported records receive an explicit status; this is a material coverage limitation, not an optional user selection. Detailed AsRun retention is 90 days, so long-pending records without a completed encrypted snapshot may become unsupported and show that status. The normal saved session remains in OpenPump. + +Verified GT SQL deduplication uses `(user_id, partner_app_id, external_session_id)`, including same-account relinks; approval logic reuses the active app/user link while rotating grants. No SQL/RPC/server changes were performed. + +Existing optional `routines:read` remains a **read-only preview**, not routine transfer/import or an executable pump builder. No device pressures/cycle/escalation settings are invented, no routine is inserted and no hardware can start from a preview. Private preview responses are not saved and are cleared on backgrounding. There is no routine-transfer claim on the website. The future advanced builder in GT is outside this task. + +## Website and supplied screenshots + +Only `docs/site/index.html#growthtrack` is the destination. It uses the same `.wrap` width/padding as adjacent sections. Copy is compact: automatic connection behavior; fixed techniques in one granular session; advanced tracking and voluntary study/data contribution benefits. It explicitly avoids claiming measurement upload or automatic research enrolment. Roadmap 2c says Implemented; no local-branch/QA metadata appears in public copy. All original ten main steps and 21 roadmap entries/metadata are preserved. + +Three user-supplied PNGs were resolved and materialized through Library's official helper, verified as readable bytes, inspected and copied without pixel modification to `docs/site/assets/growthtrack/`. Source dimensions: dashboard327×692, girth313×550, length313×478. Desktop uses a 300px-high row; mobile a horizontal gallery with full-size image links. Aspect ratios are preserved. These screenshots show measurements and dated training charts, though no account name/email is visible. The user explicitly approved including these three screenshots unchanged. + +The existing main site's first-use liability agreement was not accepted by the agent. Live read-only DOM checks verify the section/wrap/roadmap. Desktop and390px mobile screenshots use an isolated local visual QA fixture built from the exact current section HTML and styles; all three images load and the document has no horizontal overflow. This is layout evidence, not acceptance of the site's notice. + +## Completed verification + +Final `:core:test`: BUILD SUCCESSFUL in3m49s; **2,140 JUnit tests,0 failures,0 errors**. Includes57 protocol/mapping/integration mocks,12 automatic-sync mocks and5 onboarding tests. Legacy SelfTest35,830/0, Scenarios58/0, MigrationOK, Wiring1,787 self-tests/0 source violations. Earlier wiring violations were fixed in UI code; checks were not weakened. The final source-only wiring follow-up also passed in2m44s with453 pure/46 Android sources and0 violations. + +Automatic mocks cover completion without selection, every distinct record appending, duplicate completion before/after receipt, disconnected/preconnection exclusion, offline durable delay/restart retry, filing-marker crash recovery, relink and disconnect/in-flight races, simulations/invalid/unrepresentable status, partial permanent/transient results, paused permissions and migration from old manual-only consent. Fixtures are fictional; mocked transport cannot reach GT. + +Java17 parsing checks all45 main Android source files, and XML/source/whitespace checks pass. These are **not Android API/type compilation**. Hemmaservern has TemurinJDK21.0.7, Java17 target, Gradle8.11.1/AGP8.7.3 and F: Gradle cache. No Android SDK/adb/emulator was found. Per the user's instruction, no SDK/emulator/BlueStacks installation or license acceptance was attempted. + +## Required developer validation + +Use an already authorized Android environment with JDK17+, SDK/platform34 and build-tools34.0.0. Do not add secrets/signing keys. With no Android runtime currently available, the following remain **not run**: + +```powershell +.\gradlew.bat :core:test --console=plain +.\gradlew.bat :app:compileDebugJavaWithJavac :app:lintDebug :app:assembleDebug --console=plain +``` + +1. **Build/API:** compile/dex/lint on minAPI24/25 and current Android; inspect release manifest, exact callback, private Activity, BIND_JOB_SERVICE, INTERNET/ACCESS_NETWORK_STATE, persisted-job boot permission, HTTPS/backup rules and absence of debug console. Check assigned-network API28 guards and all Keystore/minSDK calls. +2. **Consent/UI:** fresh Ready offer/Skip; existing users no offer; Back/decline/cancel/relaunch no nag; own account shown in browser; dialog explicitly authorizes all future new session transfers. No session picker/Send/technique override. Summary/status remains accurate under locks, rotation and stale Activity callbacks. +3. **Automatic completion:** connect, file multiple fictional real training sessions, including manual/ordinary and stopped-with-work records; all supported records queue/send automatically once. No transfer from simulations, disconnected/preconnection history or another grant. Unsupported records show reasons. Verify connection cache initialized before a first filed record and recovery after process kill between model save and encrypted queue save. Do not operate attached pump hardware for this data QA. +4. **Retry jobs:** airplane mode, intermittent networking, network switch/VPN, metered connection, Android14 permission, Doze/standby/quota, app background, process kill, reboot, force-stop/reopen, JobService stop while queued/active and schedule refusal. Respect persisted delay and bound same IDs; no busy loop or cancelled job interfering with OAuth. Every network/storage operation remains off UI/pump threads. Verify jobFinished/replacement order on device. +5. **OAuth/rotation:** write-only/write+read, exact own-account scopes, decline/no-browser/cancel, strict forged/stale/duplicate callbacks, cold/background/recreated callbacks,900s expiry/401 refresh, rotated credential saved first, second401/410/revoke,403 pause. Crash or lose response during code/refresh exchange: no old-credential replay. Concurrent operations never rotate in parallel. +6. **Secure storage:** AES-GCM integrity/random IV/AAD, AtomicFile failure/recovery, disk full, tampered/truncated/overlarge data, missing/invalidated Keystore key. No plaintext/token logging/preferences/backup/Intent leaks. Reset remains usable and erase-all removes file/key/job. Restored model markers cannot restore link authority. +7. **Labels/timing:** 6/6.001,30/30.001,120/120.001 boundaries;6s hold+80s release remainsMilking; continuous zero release; verified >255s stitches and interrupted last chunk. Compare confirmed command-phase projection to approved device recordings; ambiguous paused/lost/changed stitches report unsupported. Frozen Length survives rename/edit/delete; free text never determines it. Length cannot bypass timestamp facts. +8. **Payload/results:** one mixed-technique sitting remains one ID/session with proper set order. Verify original times, elapsed/release/net distinctions, kPa→inHg, missing optional telemetry, whole-second/null fractions and no measurements/photos/notes. Test100-record/UTF8byte batching,413 split/single failure,422,408,429,5xx, unknown/duplicate result IDs, incomplete totals, permanent/transient mixtures and lost-response server dedup. Source edits after enqueue cannot alter retries. +9. **Account/disconnect races:** reconnect while queued, same/different own test accounts, reset during token/upload and before a queued operation enters HTTP; no old-account payload carryover. A possible in-flight commit is stated accurately. Previously received records remain, local erasure does not claim server revoke; Connected apps removal stops access. +10. **Safety/privacy:** app-lock whole/settings/history and secure-window preferences; failed unlock reveals nothing; optional private preview cache cleared on background. Browser/keyguard/new screen refused during unsafe hold/RunService; no second owner, pump command, lost STOP handle or timing change. Automatic data-only background work must not interfere with existing pump behavior. +11. **Existing routine preview:** extra scope only, own/favorite/community authorization, matching UUID/schema,404/deleted access, own notes and expanded order/rest handling. No executable import, pressure substitution or routine builder was added. +12. **Website:** main inline links, 2c desktop/mobile roadmap, preserved original entries, no separate-page links/dev QA copy, keyboard/focus/full-size images, mobile swipe, readable aspect ratios and approved screenshot publication. + +Do not release until Android/platform checks and command-phase timing assumptions are verified. This local implementation does not claim end-to-end production or device validation. diff --git a/docs/growthtrack-developer-message-draft.md b/docs/growthtrack-developer-message-draft.md new file mode 100644 index 0000000..02b8202 --- /dev/null +++ b/docs/growthtrack-developer-message-draft.md @@ -0,0 +1,19 @@ +# Message draft for the OpenPump developer — NOT SENT + +Hi, + +I've implemented an optional GrowthTrack connection in my OpenPump fork on `codex/growthtrack-integration`. Browser approval now enables automatic sync of every new real finished session to the user's own account. There is no per-session picker, review or Send step. Disconnected and preconnection history is not uploaded; unsupported recordings show their reason. + +The implementation has public PKCE, Keystore-encrypted state, a durable same-grant completion queue, stable IDs, offline/backoff retries through Android jobs, partial-result statuses and local disconnect. A persisted non-secret completion marker recovers the filing/queue crash window without moving old-account records to a new grant. Reconnect clears pending work for the previous approval. + +Technique labels are fixed: positive holds through6s are Milking, through30 RIP, through120 Interval pumping and longer Static; structured Length overrides these. Recorded command phases are clipped and joined only across proven stitch boundaries. Release/rest do not determine the labels, and mixed techniques remain one GT session with granular detail. OP measurements/photos/notes are not uploaded. + +The Ready screen offers connection once with Skip; Settings remains available later. The existing website now has one compact inline GrowthTrack section and completed roadmap substep2c, plus three supplied screenshots. The separate page and public branch/QA commentary were removed. The screenshots contain measurements/training dates, and I approved including all three unchanged in my public fork. No site was deployed. + +Material limits remain: old Sess.ts meanings vary across releases, so ambiguous old/rejoined timestamps are not guessed; unknown pressure-phase histories fail closed. Labels use saved acknowledged command phases, not reconstructed measured plateaus. Existing routine access is read-only preview; no executable import or new GT routine builder was built. + +All2,140 JVM tests passed, including57 integration,12 auto-sync and5 onboarding mocks, plus legacy, migration and unchanged wiring checks. Android compilation/lint, Keystore, browser callbacks, background-job lifecycle/network behavior and device timing still need verification. The handoff contains setup commands and a concrete checklist. + +The checkout is on Hemmaservern at `F:\Codex\OpenPump-App`. Review the implementation on https://github.com/kwikmn/OpenPump-App/tree/codex/growthtrack-integration. Main has not been merged; no PR, deployment, live OAuth/data transfer or hardware test was made. GrowthTrack source is unchanged. Its advanced routine builder remains a separate future task. + +Please review `docs/growthtrack-developer-handoff.md` and `docs/growthtrack-verification.md` before considering release. diff --git a/docs/growthtrack-verification.md b/docs/growthtrack-verification.md new file mode 100644 index 0000000..df6bd87 --- /dev/null +++ b/docs/growthtrack-verification.md @@ -0,0 +1,44 @@ +# GrowthTrack verification record + +Final behavior: connection authorizes automatic sync of every new real finished session; disconnected/preconnection history sends none. No per-session selection/review/Send UI. Unsupported recordings have explicit status. Historical bulk import and a GrowthTrack routine builder are outside scope. + +## Passed locally + +| Check | Evidence | +|---|---| +| Full JVM suite | `:core:test` BUILD SUCCESSFUL3m49s;2,140 JUnit,0 failures,0 errors. | +| Feature mocks |57 integration/protocol/mapping +12 automatic-sync +5 onboarding. All fictional HTTP/storage fixtures; no live GT request. | +| Legacy harness | SelfTest35,830 passed,0 failed; Scenarios58/0; MigrationOK, including unknown historical timestamps and completion-grant marker round-trip. | +| Unchanged wiring |1,787 self-tests; final source scan0 violations. Final Android network-source follow-up BUILD SUCCESSFUL2m44s;453 pure/46 Android sources,0 violations. | +| Android syntax | Java17 parse45 main Java sources,0 grammar errors; **no Android API/type compile**. | +| Manifest/source | Exact callback, private status Activity, protected retry JobService, INTERNET/ACCESS_NETWORK_STATE, backup disabled and release cleartext disabled; no pump calls or credential logging in feature classes. | +| Site source/JS |21 original roadmap entries and metadata preserved;10 main steps;2c in both layouts;6 inline scripts parse. No separate-page destination or public local/QA status. | +| Images |3 official Library-materialized PNGs readable/inspected; unmodified source/site SHA256 matches;327×692,313×550,313×478;full-size links. | +| Layout | Same wrap and padding as adjacent section. Exact-source isolated fixture at default desktop/390px mobile;3 images loaded; no document horizontal overflow; mobile gallery scrolls internally. | +| Whitespace | `git diff --check`, no errors. | + +The initial automatic-sync run stopped on two unchanged wiring rules (onStop service guard and a142-character face note). Both source violations were corrected; no check was altered, disabled or deleted. The completed full rerun then passed. + +Automatic cases cover new completion without selection, every distinct completion appending while work is pending, duplicate completion before/after receipt, disconnected/preconnection exclusion, durable offline delay/restart, filing/queue crash-gap recovery, old/new grant isolation, in-flight disconnect and remaining batches, simulated/invalid/unsupported statuses, partial transient/permanent results, paused missing permission and old manual-only consent requiring a fresh link. + +## Platform checks still required + +Android SDK/adb/emulator absent on Hemmaservern. The user explicitly chose no SDK/emulator/BlueStacks setup or license acceptance. Android compile/dex/lint, actual Keystore/AtomicFile, system browser callback/lifecycle/locks, JobScheduler/network/Doze/reboot/force-stop behavior, consent/end-to-end GT ingest and device timing remain **NOT RUN**. Source parsing and mocked JVM checks cannot confirm these. See the concrete checklist and commands in `growthtrack-developer-handoff.md`. + +Material limits: labels project saved acknowledged command phases, not sampled pressure plateaus; ambiguous paused/lost/stitch histories and missing original timestamps remain unsupported. Old Sess.ts meaning varies across releases; old/rejoined boundaries are not invented. GT durations are whole seconds/null for subsecond work. Invalid new records show reasons, never optional selection. Android controls background delivery timing; an in-flight commit cannot be recalled. Local disconnect differs from remote Connected apps revocation. No executable routine import or new GT builder is present. + +## Website evidence and privacy + +Main preview: `http://127.0.0.1:8766/index.html#growthtrack`, HTTP200; loopback server remains alive. Separate growthtrack.html removed. Compact inline card has automatic sync, one mixed-method session and advanced/voluntary tracking/study benefits; no routine-transfer claim. + +The main site's first-use liability agreement was not accepted. Live read-only DOM verifies the section/roadmap; screenshots were made using an isolated fixture from the exact current HTML and stylesheet, avoiding changes to the agreement or production page. Local screenshot files: `growthtrack-website-desktop.png`, `growthtrack-website-mobile.png` in the task workspace. Temporary QA server is stopped after verification; main8766 remains alive. + +Supplied screenshots contain genital measurements and dated training charts, with no account name/email observed. The user explicitly approved publishing all three unchanged in the public personal fork on 10 October 2026. This publication does not deploy the website. + +## Repository state + +Fork `https://github.com/kwikmn/OpenPump-App`; path `F:\Codex\OpenPump-App`; branch `codex/growthtrack-integration`; baseline `1dddec436e552b0669e69427e9d054a71598d859`; origin personal fork, upstream OpenByte-source/OpenPump-App. Implementation source/docs/assets are carried by the [personal-fork feature branch](https://github.com/kwikmn/OpenPump-App/tree/codex/growthtrack-integration). Publishing it does not merge main or deploy the site. The developer draft is unsent; no PR, live grant, real data transfer or hardware use occurred. Existing CI triggers only on main pushes or pull requests, so this feature-branch push alone does not run CI. + +GT checkout remains tracked-clean on `codex/routine-intensity-defaults`, with the same three preexisting untracked docs. No GT file was changed. + +Logs in task workspace: `growthtrack-auto-tests-final.log`, `growthtrack-auto-wiring-final.log`; JUnit XML/HTML in ignored core build output. Private Library replacements preserve the existing four attachment identities; results are recorded separately after replacement. diff --git a/docs/site/assets/growthtrack/GTdoseresponsegirthwork.png b/docs/site/assets/growthtrack/GTdoseresponsegirthwork.png new file mode 100644 index 0000000..f871b23 Binary files /dev/null and b/docs/site/assets/growthtrack/GTdoseresponsegirthwork.png differ diff --git a/docs/site/assets/growthtrack/GTdoseresponselengthwork.png b/docs/site/assets/growthtrack/GTdoseresponselengthwork.png new file mode 100644 index 0000000..f31a9e3 Binary files /dev/null and b/docs/site/assets/growthtrack/GTdoseresponselengthwork.png differ diff --git a/docs/site/assets/growthtrack/GTscreen.png b/docs/site/assets/growthtrack/GTscreen.png new file mode 100644 index 0000000..23d9657 Binary files /dev/null and b/docs/site/assets/growthtrack/GTscreen.png differ diff --git a/docs/site/index.html b/docs/site/index.html index 5689519..00ad0f6 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -45,8 +45,8 @@ - body imagery - that more than one pump works today. Other pumps are a goal: "any Bluetooth pump whose protocol can be reverse engineered" -- privacy wording other than: nothing leaves your phone unless you export, back up or share it - yourself. Never "no internet permission" (debug builds declare it, for the diagnostic +- privacy wording other than: nothing leaves your phone unless you export, back up, share it or + connect your own GrowthTrack account for automatic session sync. Never "no internet permission" (debug builds declare it, for the diagnostic console, which the release APK does not carry) - a fixed pressure limit. The ceiling is user-set, 7 to 57 kPa, 40 by default - "vented" before telemetry proves it @@ -78,6 +78,14 @@ @font-face{font-family:'Familjen Grotesk';font-style:normal;font-weight:600 700;font-display:swap;src:url(data:font/woff2;base64,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) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} @font-face{font-family:'Karla';font-style:normal;font-weight:400 700;font-display:swap;src:url(data:font/woff2;base64,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) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;} @font-face{font-family:'Karla';font-style:normal;font-weight:400 700;font-display:swap;src:url(data:font/woff2;base64,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) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} + +/* GrowthTrack: full screenshots in a compact row; swipe on small screens. */ +.gt-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;max-width:900px;margin:28px 0 10px} +.gt-gallery figure{margin:0;padding:12px;border:1px solid var(--rule,#C3CBD1);min-width:0} +.gt-gallery a{display:block;text-decoration:none}.gt-gallery img{display:block;width:auto;max-width:100%;height:300px;object-fit:contain;margin-inline:auto} +.gt-gallery figcaption{font-size:13px;line-height:1.5;margin-top:10px;text-align:center} +.gt-gallery-hint{font-size:13px} +@media(max-width:640px){.gt-gallery{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:14px;padding-bottom:12px}.gt-gallery figure{flex:0 0 min(78vw,260px);scroll-snap-align:start}.gt-gallery img{height:300px}}