Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6938ce3
docs(ios-sync): triage iOS PR #93 (Colmi R11 CRP driver)
foureight84 Aug 22, 2026
f38d0a3
docs(ios-sync): fix the resume block — three threads, not two
foureight84 Aug 22, 2026
a6e35c1
docs(crp): implementation plan for the #93 CRP hardening items
foureight84 Aug 22, 2026
701427e
docs(ios-sync): one consolidated outstanding list
foureight84 Aug 22, 2026
e7a1c58
docs(ios-sync): correct the branch note — merged-and-deleted vs merge…
foureight84 Aug 22, 2026
b961af8
Merge main into ios-sync-triage-2026-08-22 (sync before work)
foureight84 Aug 22, 2026
c95b6e8
fix(crp): port the iOS R11 driver hardening (PR #93 review)
foureight84 Aug 22, 2026
678467f
docs(ios-sync): close out #93 CRP hardening (ported in c95b6e8)
foureight84 Aug 22, 2026
9d43227
Port iOS #94 CoachNotificationDataTrigger (run due slot on sync compl…
foureight84 Aug 22, 2026
d36b923
Ledger: #94 CoachNotificationDataTrigger done (9d43227)
foureight84 Aug 22, 2026
71f251e
Write activity pause events so Strava TCX drops paused trackpoints
foureight84 Aug 22, 2026
0882f48
Ledger: workout pause intervals done (71f251e)
foureight84 Aug 22, 2026
a13238d
Port iOS #96 Open Food Facts client + cache (nutrition foundation)
foureight84 Aug 22, 2026
05d8833
Port iOS #96 coach nutrition tools (search/log/get/update/delete meals)
foureight84 Aug 22, 2026
ce6218c
Record #96 nutrition: OFF client + coach tools landed (a13238d, 05d88…
foureight84 Aug 22, 2026
c9be848
Port RWfit JieLi 0xAB 05-group history bodies (finish the JieLi path)
foureight84 Aug 23, 2026
04a6fcd
Ledger: #130 RWfit JieLi 0xAB history decode done (c9be848)
foureight84 Aug 23, 2026
d3d1371
Honor a caller-supplied JSON schema on the self-hosted provider
foureight84 Aug 23, 2026
e80c76c
Port iOS #96 barcode scanner + AI meal analysis (finish the nutrition…
foureight84 Aug 23, 2026
5f8e197
Ledger: #96 nutrition complete (e80c76c); port queue now empty
foureight84 Aug 23, 2026
0765b37
Default meal confidence to "known" like iOS, and normalize the rows t…
foureight84 Aug 23, 2026
b57fca7
Ledger: record the meal-confidence fix and this session's carry-forwa…
foureight84 Aug 23, 2026
fb249fa
Address PR #53 review findings and bump versionCode to 39
foureight84 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 38
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 39
versionName = (project.findProperty("appVersionName") as String?) ?: "2.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down Expand Up @@ -172,6 +172,23 @@ dependencies {
// series, so it is a faithful API reference for this pin.
implementation("androidx.health.connect:connect-client:1.1.0")

// Phase 9 (iOS #96 stage A): barcode scanner. ML Kit's bundled-model artifact runs
// regardless of Play Services state (the -play-services variant would dead-end on
// devices without the updated services). CameraX 1.4.x for preview + analysis.
implementation("com.google.mlkit:barcode-scanning:17.3.0")
val cameraXVersion = "1.4.2"
implementation("androidx.camera:camera-core:$cameraXVersion")
// The CameraX camera2 implementation artifact is "camera-camera2" (the partial's
// "camera2" coordinate does not exist on Google Maven).
implementation("androidx.camera:camera-camera2:$cameraXVersion")
implementation("androidx.camera:camera-lifecycle:$cameraXVersion")
implementation("androidx.camera:camera-view:$cameraXVersion")

// CameraX's ProcessCameraProvider.getInstance() exposes Guava ListenableFuture in its
// signature, but the graph also carries Google's "9999.0-empty-to-avoid-conflict-with-guava"
// stub, which strips the class at compile time. Full guava restores it.
implementation("com.google.guava:guava:33.3.1-android")

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
<!-- Phase 7: Notifications for coach check-ins (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Phase 9 (iOS #96 stage A): barcode scanner. The scanner screen falls back to a
message when the permission is denied or no camera exists (iOS
BarcodeScannerSheet.swift:20-39), so the camera is NOT required. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />

<!-- Phase 4+: Foreground service for workout. Android 14+ requires the "health" foreground
service type to be paired with at least one of ACTIVITY_RECOGNITION/BODY_SENSORS/
HIGH_SAMPLING_RATE_SENSORS or startForeground() throws SecurityException — this was
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,25 @@ class LocalOpenAICompatClient(
val tools = (req["tools"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList()
val previousResponseId = (req["previous_response_id"] as? JsonPrimitive)?.contentOrNull

if (previousResponseId == null) setupConversation(input)
// A caller-supplied strict schema (`text.format`) — the contract every other adapter
// already honors (OpenRouterClient.chatResponseFormat). Non-chat callers such as the
// meal estimator and the summary generator ask for their OWN json_schema here; without
// this the coach chat's `coach_response` schema was substituted for theirs, which on a
// guided-decoding backend made their reply impossible to produce, not merely unlikely.
val callerFormat = callerJsonSchema(req)

if (previousResponseId == null) setupConversation(input, callerFormat != null)
else appendContinuation(previousResponseId, input)

return buildChatBody(if (toolCallingEnabled) convertTools(tools) else emptyList())
return buildChatBody(if (toolCallingEnabled) convertTools(tools) else emptyList(), callerFormat)
}

/** The caller's `text.format` when it is a usable json_schema block, else null. */
internal fun callerJsonSchema(req: JsonObject): JsonObject? {
val format = (req["text"] as? JsonObject)?.get("format") as? JsonObject ?: return null
if ((format["type"] as? JsonPrimitive)?.contentOrNull != "json_schema") return null
if (format["schema"] !is JsonObject) return null
return format
}

// ── Conversation setup ───────────────────────────────────────────────
Expand All @@ -122,7 +137,7 @@ class LocalOpenAICompatClient(
* joins the system block rather than trailing the conversation (where MiniMax puts it) because
* a system turn after a user turn raises in several local chat templates.
*/
private fun setupConversation(input: List<JsonObject>) {
private fun setupConversation(input: List<JsonObject>, callerSuppliedSchema: Boolean = false) {
messages = mutableListOf()
storedAssistantMessage.clear()

Expand All @@ -143,8 +158,10 @@ class LocalOpenAICompatClient(
}
// Only the prompt tells an unconstrained local model what shape to answer in. Even with
// `response_format` on, this stays — it's what the orchestrator's JSON-repair loop leans on
// when a small model ignores the grammar.
systemParts.add(CoachResponseSchema.promptInstruction)
// when a small model ignores the grammar. A caller that brought its OWN schema gets the
// instruction for THAT schema instead: injecting `coach_response` there told the model to
// answer in a shape its caller cannot parse.
if (!callerSuppliedSchema) systemParts.add(CoachResponseSchema.promptInstruction)
systemPrompt = systemParts.filter { it.isNotBlank() }.joinToString("\n\n")
messages.addAll(conversation)
}
Expand Down Expand Up @@ -247,8 +264,11 @@ class LocalOpenAICompatClient(

// ── Build request body ───────────────────────────────────────────────

internal fun buildChatBody(tools: List<JsonObject>): JsonObject {
internal fun buildChatBody(tools: List<JsonObject>, callerFormat: JsonObject? = null): JsonObject {
val allMessages = mutableListOf<JsonObject>()
val systemPrompt = if (callerFormat == null) systemPrompt
else listOf(systemPrompt, schemaInstruction(callerFormat))
.filter { it.isNotBlank() }.joinToString("\n\n")
if (systemPrompt.isNotBlank()) {
allMessages.add(JsonObject(mapOf(
"role" to JsonPrimitive("system"),
Expand All @@ -264,7 +284,7 @@ class LocalOpenAICompatClient(
"messages" to JsonArray(allMessages),
)
if (tools.isNotEmpty()) body["tools"] = JsonArray(tools)
responseFormat()?.let { body["response_format"] = it }
responseFormat(callerFormat)?.let { body["response_format"] = it }
maxOutputTokens?.takeIf { it > 0 }?.let { body["max_tokens"] = JsonPrimitive(it) }
return JsonObject(body)
}
Expand All @@ -276,19 +296,46 @@ class LocalOpenAICompatClient(
* LM Studio and recent llama.cpp all accept. `JSON_OBJECT` is the older, weaker mode; LM
* Studio doesn't implement it, hence the choice.
*/
internal fun responseFormat(): JsonObject? = when (structuredOutput) {
internal fun responseFormat(callerFormat: JsonObject? = null): JsonObject? = when (structuredOutput) {
// The user picked OFF because their backend rejects `response_format` outright. A caller's
// schema does not override that — it travels in the prompt instead, and every structured
// caller here decodes fence-tolerantly.
LocalStructuredOutput.OFF -> null
LocalStructuredOutput.JSON_OBJECT -> JsonObject(mapOf("type" to JsonPrimitive("json_object")))
LocalStructuredOutput.JSON_SCHEMA -> JsonObject(mapOf(
"type" to JsonPrimitive("json_schema"),
"json_schema" to JsonObject(mapOf(
"name" to JsonPrimitive("coach_response"),
"name" to JsonPrimitive(
(callerFormat?.get("name") as? JsonPrimitive)?.contentOrNull ?: "coach_response"),
"strict" to JsonPrimitive(true),
"schema" to CoachResponseSchema.schema,
"schema" to (callerFormat?.get("schema") as? JsonObject ?: CoachResponseSchema.schema),
)),
))
}

/**
* The prompt-side statement of a caller-supplied schema, standing in for
* [CoachResponseSchema.promptInstruction]. It is what carries the shape when the user's
* Response format is OFF, and the backup when a small model ignores the grammar.
*/
internal fun schemaInstruction(callerFormat: JsonObject): String {
val name = (callerFormat["name"] as? JsonPrimitive)?.contentOrNull ?: "response"
val schema = callerFormat["schema"] as? JsonObject ?: return ""
// The coach chat sends its OWN `coach_response` text.format on every turn
// (CoachOrchestrator.coachResponseTextFormat), so this is the chat's normal path too —
// not just the meal estimator's. Its hand-written instruction says strictly more than a
// schema dump (no "message" key, put the answer in "summary", the length caps) and the
// orchestrator's JSON-repair loop leans on that wording, so keep it for that schema
// instead of degrading the main local-LLM path to raw JSON Schema.
if (name == "coach_response" && schema == CoachResponseSchema.schema) {
return CoachResponseSchema.promptInstruction
}
return "Your final answer MUST be a single JSON object (no Markdown, no code fences, " +
"no prose before or after) matching this exact `$name` JSON Schema. Every key listed " +
"in \"required\" must be present:\n" +
json.encodeToString(JsonObject.serializer(), schema)
}

// ── Parse Chat Completions response → OpenAIResponse (internal for tests) ─

internal fun ingestResponse(root: JsonObject): OpenAIResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ data class PendingAction(
val summary: String, // human-readable description for the card
val confirmLabel: String,
val updates: ActivityUpdates? = null, // only for updateActivitySession
val mealUpdates: MealUpdates? = null, // only for updateMealEntry (iOS #96)
) {
fun toJson(): String = Json.encodeToString(serializer(), this)

Expand All @@ -32,6 +33,11 @@ data class PendingAction(
enum class PendingActionKind {
DELETE_ACTIVITY_SESSION,
UPDATE_ACTIVITY_SESSION,
// Meal actions (iOS #96). The target meal id rides [PendingAction.activityId] — the field
// is named for the original activity actions and meal actions reuse it so older persisted
// cards keep decoding.
DELETE_MEAL_ENTRY,
UPDATE_MEAL_ENTRY,
}

/**
Expand All @@ -46,3 +52,18 @@ data class ActivityUpdates(
val perceivedEffort: String? = null,
val startTime: String? = null,
)

/**
* Field updates for updateMealEntry (null = leave unchanged).
* Ported from MealUpdates in PendingAction.swift (iOS PR #96).
*/
@Serializable
data class MealUpdates(
val name: String? = null,
val mealType: String? = null,
val calories: Double? = null,
val proteinG: Double? = null,
val carbsG: Double? = null,
val fatG: Double? = null,
val notes: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ object PendingActionExecutor {
)

suspend fun execute(action: PendingAction, db: PulseLoopDatabase): String {
// Meal actions (iOS #96) target a MealEntry, not an ActivitySession — branch before
// the session lookup so a meal id is never matched against activity ids.
if (action.kind == PendingActionKind.UPDATE_MEAL_ENTRY ||
action.kind == PendingActionKind.DELETE_MEAL_ENTRY
) {
return executeMeal(action, db)
}

val sessions = db.activitySessionDao().recent(200)
val session = sessions.firstOrNull { it.id == action.activityId }
?: return "That workout no longer exists."

val typeLabel = activityLabels[session.type] ?: session.type

return when (action.kind) {
// Meal kinds are handled by the early return above (before the session lookup, since
// a meal id is never an activity id); these keep the when exhaustive.
PendingActionKind.DELETE_MEAL_ENTRY,
PendingActionKind.UPDATE_MEAL_ENTRY -> executeMeal(action, db)
PendingActionKind.DELETE_ACTIVITY_SESSION -> {
com.pulseloop.service.ActivityRollup.reverse(db, session)
db.activitySessionDao().upsert(
Expand All @@ -43,6 +55,34 @@ object PendingActionExecutor {
}
}

/**
* Ported from executeMeal in PendingActionExecutor.swift (iOS PR #96). The meal id rides
* [PendingAction.activityId] — the field is named for the original activity actions and
* meal actions reuse it (older persisted cards keep decoding). Updates apply through
* [com.pulseloop.coach.tools.NutritionTools.applyMealUpdates], the same pure function the
* today-path of the update_meal_entry tool uses, so both paths behave identically.
*/
private suspend fun executeMeal(action: PendingAction, db: PulseLoopDatabase): String {
val entry = db.mealEntryDao().byId(action.activityId)
?: return "That meal no longer exists."
return when (action.kind) {
PendingActionKind.DELETE_MEAL_ENTRY -> {
val name = entry.name
db.mealEntryDao().deleteById(entry.id)
"Deleted \"$name\"."
}
PendingActionKind.UPDATE_MEAL_ENTRY -> {
val updated = action.mealUpdates?.let {
com.pulseloop.coach.tools.NutritionTools.applyMealUpdates(it, entry)
} ?: entry
db.mealEntryDao().upsert(updated)
"Updated \"${updated.name}\"."
}
// Unreachable: execute() routes only the two meal kinds here.
else -> ""
}
}

/**
* What an [ActivityUpdates] resolves to against a specific session: the target type/start/end
* plus whether that window actually differs from the session's current one. Pure — no DB —
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ data class ToolExecutionContext(
val flags: CoachFeatureFlags = CoachFeatureFlags(),
val coordinator: com.pulseloop.service.RingSyncCoordinator? = null, // for live measurements
val pendingActions: MutableList<com.pulseloop.coach.orchestration.PendingAction> = mutableListOf(),
/**
* Open Food Facts lookup for the nutrition tools (iOS #96 `search_food_database`).
* Null = the search tool reports `database_unavailable` and the model must fall back to a
* labeled estimate; defaults to null so test harnesses and pre-existing construction sites
* compile unchanged (iOS's ToolExecutionContext has the same optional foodClient).
*/
val foodClient: com.pulseloop.nutrition.FoodDatabaseClient? = null,
)

data class CoachFeatureFlags(
Expand Down
Loading