Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Thumbs.db
.agents/
.impeccable/
artifacts/
# Browser automation session snapshots and console logs
.playwright-cli/
# Local CI artifact downloads and rendered previews
output/
PROJECT.md
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ android {
minSdk = 26
targetSdk = 36
versionCode = providers.gradleProperty("IRONLOG_VERSION_CODE").orNull?.toIntOrNull()
?: 11
?: 12
versionName = providers.gradleProperty("IRONLOG_VERSION_NAME").orNull
?: "0.1.0-pre-alpha.10"
?: "0.1.0-pre-alpha.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ class ExerciseRepository(
observeQuery { exercisesBox.query().build() }.map { rows -> filterRows(rows, filters) }

suspend fun getExercisesSnapshot(filters: ExerciseFilters = ExerciseFilters()): List<LegacyExerciseShape> =
withContext(Dispatchers.IO) { filterRows(exercisesBox.all, filters).map(::mapExerciseRowToLegacyShape) }
withContext(Dispatchers.IO) { catalogRows(filters) }

suspend fun getCompactCatalogMarkdown(): String = withContext(Dispatchers.IO) {
val canonical = context?.let { catalogMarkdownFile(it) }
if (canonical != null && canonical.exists()) {
return@withContext canonical.readText()
}
val generated = buildCompactCatalogMarkdown(filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape))
val generated = buildCompactCatalogMarkdown(catalogRows())
canonical?.let {
it.parentFile?.mkdirs()
it.writeText(generated)
Expand All @@ -80,8 +80,6 @@ class ExerciseRepository(
requireNonEmpty(input.equipment, "equipment")
requireNonEmpty(input.category, "category")
val normalized = normalizeExerciseNameKey(name)
val duplicate = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalized)).build().use { it.find() }
if (duplicate.isNotEmpty()) error("Exercise with this name already exists.")

// Validate every child before the parent is written so a bad contribution cannot leave
// behind a partially-created exercise.
Expand Down Expand Up @@ -111,6 +109,7 @@ class ExerciseRepository(
secondaryMusclesJson = input.secondaryMuscles?.let { org.json.JSONArray(it).toString() }
}
store.runInTx {
requireUniqueName(normalized)
exercisesBox.put(created)
val muscleCreates = input.muscles.orEmpty().map { m ->
ExerciseMuscleEntity().apply {
Expand All @@ -131,22 +130,26 @@ class ExerciseRepository(

suspend fun updateExercise(id: String, input: UpdateExerciseInput): ExerciseEntity = withContext(Dispatchers.IO) {
requireNonEmpty(id, "id")
val exercise = findExerciseByUidOrThrow(id)
val now = System.currentTimeMillis()
if (!input.name.isNullOrEmpty()) {
val updatedName = normalizeExerciseName(input.name)
exercise.name = updatedName
// Use normalizeExerciseName first to get a non-null String, then call the
// String overload of normalizeExerciseNameKey (underscore format) — consistent
// with createCustomExercise which also stores in underscore format.
exercise.normalizedName = normalizeExerciseNameKey(updatedName)
val updatedName = input.name?.let { normalizeExerciseName(it).also { name -> requireNonEmpty(name, "name") } }
input.primaryMuscle?.let { requireNonEmpty(it.trim(), "primary_muscle") }
input.equipment?.let { requireNonEmpty(it.trim(), "equipment") }
input.category?.let { requireNonEmpty(it.trim(), "category") }
lateinit var exercise: ExerciseEntity
store.runInTx {
exercise = findExerciseByUidOrThrow(id)
if (updatedName != null) {
val normalized = normalizeExerciseNameKey(updatedName)
requireUniqueName(normalized, exercise.objectBoxId)
exercise.name = updatedName
exercise.normalizedName = normalized
}
input.primaryMuscle?.let { exercise.primaryMuscle = it.trim() }
input.equipment?.let { exercise.equipment = it.trim() }
input.category?.let { exercise.category = it.trim() }
input.notes?.let { exercise.notes = it }
exercise.updatedAt = System.currentTimeMillis()
exercisesBox.put(exercise)
}
if (!input.primaryMuscle.isNullOrEmpty()) exercise.primaryMuscle = input.primaryMuscle
if (!input.equipment.isNullOrEmpty()) exercise.equipment = input.equipment
if (!input.category.isNullOrEmpty()) exercise.category = input.category
if (input.notes != null) exercise.notes = input.notes.ifEmpty { "" }
exercise.updatedAt = now
exercisesBox.put(exercise)
syncCompactCatalogMarkdown()
exercise
}
Expand Down Expand Up @@ -184,7 +187,7 @@ class ExerciseRepository(
suspend fun buildExerciseAlternatives(exerciseId: String, filters: ExerciseFilters = ExerciseFilters()): SubstitutionEngine.AlternativesResult =
withContext(Dispatchers.IO) {
requireNonEmpty(exerciseId, "exerciseId")
val exercises = exercisesBox.all.map(::mapExerciseRowToLegacyShape)
val exercises = catalogRows()
val target = exercises.find { it.id == exerciseId || it.exerciseId == exerciseId }
?: return@withContext SubstitutionEngine.AlternativesResult.EMPTY
SubstitutionEngine.buildExerciseAlternatives(target, exercises, filters, filters.limit ?: 12)
Expand All @@ -197,8 +200,33 @@ class ExerciseRepository(
internal fun findExerciseByName(name: String): ExerciseEntity? =
exercisesBox.query(ExerciseEntity_.name.equal(name)).build().use { it.findFirst() }

internal fun mapExerciseRowToLegacyShape(row: ExerciseEntity): LegacyExerciseShape {
val primary = if (row.primaryMuscle.isNotBlank()) listOf(row.primaryMuscle) else emptyList()
/** Called inside the owning write transaction; duplicate legacy rows are not rewritten. */
private fun requireUniqueName(normalized: String, exceptId: Long = 0) {
val duplicate = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalized)).build().use { query ->
query.find().any { it.objectBoxId != exceptId }
}
if (duplicate) error("Exercise with this name already exists.")
}

/** Batch-load relationships once, avoiding a lazy relation query per catalog entry. */
private fun catalogRows(filters: ExerciseFilters = ExerciseFilters()): List<LegacyExerciseShape> = store.callInReadTx {
mapExerciseRows(filterRows(exercisesBox.all, filters))
}

private fun mapExerciseRows(rows: List<ExerciseEntity>): List<LegacyExerciseShape> {
val primaryByUid = musclesBox.all.filter { it.role.equals("primary", true) }.groupBy { it.exerciseUid }
return rows.map { row -> mapExerciseRowToLegacyShape(row, primaryByUid[row.uid].orEmpty().map { it.muscle }) }
}

private fun metadataStrings(json: String?): List<String> = json?.let {
runCatching {
val array = org.json.JSONArray(it)
(0 until array.length()).mapNotNull { index -> (array.opt(index) as? String)?.trim()?.takeIf(String::isNotBlank) }.distinct()
}.getOrElse { emptyList() }
}.orEmpty()

internal fun mapExerciseRowToLegacyShape(row: ExerciseEntity, primaryRelationships: List<String> = emptyList()): LegacyExerciseShape {
val primary = (listOf(row.primaryMuscle) + primaryRelationships).map(String::trim).filter(String::isNotBlank).distinct()
val category = row.category.ifBlank { "strength" }
val trackingType = ExerciseTrackingTypeNormalizer.normalize(
name = row.name,
Expand All @@ -212,23 +240,18 @@ class ExerciseRepository(
name = row.name,
primaryMuscles = primary,
primaryMuscle = row.primaryMuscle.ifBlank { null },
secondaryMuscles = row.secondaryMusclesJson?.let { json ->
runCatching {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
}.getOrElse { emptyList() }
} ?: emptyList(),
secondaryMuscles = metadataStrings(row.secondaryMusclesJson),
equipment = row.equipment.ifBlank { "Other" },
category = category,
trackingType = trackingType,
isCustom = row.isCustom,
aliases = emptyList(),
aliases = metadataStrings(row.aliasesJson),
isBodyweight = row.isBodyweight || row.equipment.lowercase() == "bodyweight",
movementPattern = row.movementPattern,
difficulty = row.difficulty,
apparatus = row.apparatusJson,
equipmentDetail = row.equipmentDetail,
sourceTags = emptyList(),
sourceTags = metadataStrings(row.sourceTagsJson),
notes = row.notes,
)
}
Expand All @@ -244,7 +267,7 @@ class ExerciseRepository(
private fun syncCompactCatalogMarkdown() {
val ctx = context ?: return
val file = catalogMarkdownFile(ctx)
val rows = filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape)
val rows = catalogRows()
file.parentFile?.mkdirs()
file.writeText(buildCompactCatalogMarkdown(rows))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.ironlog.app.data.repository

import com.ironlog.app.data.model.CreateExerciseInput
import com.ironlog.app.data.model.UpdateExerciseInput
import com.ironlog.app.data.objectbox.ExerciseEntity
import com.ironlog.app.data.objectbox.ExerciseMuscleEntity
import com.ironlog.app.data.objectbox.MyObjectBox
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

class ExerciseRepositoryIntegrityTest {
@get:Rule val temporary = TemporaryFolder()
private fun input(name: String) = CreateExerciseInput(name = name, primaryMuscle = "Chest", equipment = "Cable", category = "strength")

@Test fun `concurrent normalized creates persist exactly one identity`() = runBlocking {
val store = MyObjectBox.builder().directory(temporary.newFolder()).build()
try {
val repo = ExerciseRepository(boxStore = store)
val results = coroutineScope {
(0 until 32).map { index -> async { runCatching { repo.createCustomExercise(input(if (index % 2 == 0) " Cable Press " else "Cable Press")) } } }.awaitAll()
}
assertEquals(1, results.count { it.isSuccess })
assertEquals(1L, store.boxFor(ExerciseEntity::class.java).count())
} finally { store.close() }
}

@Test fun `blank and colliding renames fail without modifying stored row`() = runBlocking {
val store = MyObjectBox.builder().directory(temporary.newFolder()).build()
try {
val repo = ExerciseRepository(boxStore = store)
val first = repo.createCustomExercise(input("Cable Press"))
repo.createCustomExercise(input("Cable Fly"))
for (name in listOf("", " ", " cable fly ")) {
assertTrue(runCatching { repo.updateExercise(first.uid, UpdateExerciseInput(name = name, notes = "should not save")) }.isFailure)
val unchanged = repo.getExerciseById(first.uid)
assertEquals("Cable Press", unchanged.name)
assertEquals("", unchanged.notes)
assertEquals(first.updatedAt, unchanged.updatedAt)
}
repo.updateExercise(first.uid, UpdateExerciseInput(name = " CABLE PRESS ", notes = "valid edit"))
assertEquals("valid edit", repo.getExerciseById(first.uid).notes)
} finally { store.close() }
}

@Test fun `catalog preserves aliases source tags and all primary muscle relationships`() = runBlocking {
val store = MyObjectBox.builder().directory(temporary.newFolder()).build()
try {
val row = ExerciseEntity().apply {
uid = "multi"; name = "Compound Press"; primaryMuscle = "Chest"; equipment = "Cable"
aliasesJson = "[\"Cable Compound\",\" Alternate press \"]"; sourceTagsJson = "[\"seed\",\"compound\"]"
secondaryMusclesJson = "[\"Triceps\"]"
}
store.boxFor(ExerciseEntity::class.java).put(row)
store.boxFor(ExerciseMuscleEntity::class.java).put(listOf("Chest", "Shoulders").map { name -> ExerciseMuscleEntity().apply { exercise.target = row; exerciseUid = row.uid; muscle = name; role = "primary"; contributionFraction = 0.5 } })
val mapped = ExerciseRepository(boxStore = store).getExercisesSnapshot().single()
assertEquals(listOf("Chest", "Shoulders"), mapped.primaryMuscles)
assertEquals(listOf("Cable Compound", "Alternate press"), mapped.aliases)
assertEquals(listOf("seed", "compound"), mapped.sourceTags)
assertEquals(listOf("Triceps"), mapped.secondaryMuscles)
val exported = ImportExportRepository(store, temporary.newFolder()).exportDatabase().getJSONObject("data")
assertEquals(2, exported.getJSONArray("exercise_muscles").length())
assertEquals(2, org.json.JSONArray(exported.getJSONArray("exercises").getJSONObject(0).getString("aliases_json")).length())
} finally { store.close() }
}
}
14 changes: 14 additions & 0 deletions docs/reviews/2026-10-08-cloud-transport-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Browser cloud transport evidence — 8 October 2026

Official sources checked during whole-plan implementation:
- OpenAI API overview: https://developers.openai.com/api/reference/overview
- OpenAI TypeScript SDK browser support: https://github.com/openai/openai-node#requirements
- Google API key security: https://ai.google.dev/gemini-api/docs/api-key
- Anthropic TypeScript SDK browser support: https://github.com/anthropics/anthropic-sdk-typescript#requirements
- OpenRouter REST fetch example: https://openrouter.ai/docs/quickstart

OpenAI and Anthropic SDKs document opt-in browser support while warning about credential exposure. Google advises server-side protection of production keys. This app must never distribute a project/provider credential in its bundle. A user's optional own credential can be entered explicitly for a session and used only for an action they request; the UI must disclose that browser scripts can access it. Configuration can be portable, credentials cannot. No arbitrary-URL credential proxy is authorized or required.

Provider presets are configuration, not a guarantee that an endpoint accepts the deployed browser origin. Transport failures (including CORS) must preserve manual prompt/paste, offer retry or configuration changes, and never claim generation succeeded. Custom endpoints need HTTPS (loopback HTTP only for local development), no URL credentials/query keys, no redirects carrying auth, bounded responses, timeout/cancellation and obsolete-result suppression. Requests must use headers for keys, avoid logs/raw provider error echo, and exclude photos/unrelated historical data. Built-in mode makes zero requests.

Use native protocols/presets as the parity reference; let users edit current model IDs. The above verification does not prove every region, provider plan, model or live paid account works. Tests use synthetic responses; no owner key or paid request is used in QA.
12 changes: 12 additions & 0 deletions docs/reviews/2026-10-08-native-exercise-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Native exercise integrity — 8 October 2026

Confirmed against real temporary ObjectBox stores, not source-string assertions:
1. 32 concurrent creates for whitespace/case-normalized identity could persist duplicates.
2. Blank and colliding renames were accepted, allowing invalid catalog identity.
3. Catalog mapping discarded aliases/source tags and reduced multiple primary relationships to the scalar first muscle.

All three regression tests failed before implementation. After the change all three pass, including persisted-row rollback, exact-one create and export metadata checks. The duplicate check and parent/child write share one write transaction. Rename validates before mutation, re-reads in the transaction and excludes only the edited identity. Catalog reads batch primary relationships in one read transaction; historical workout snapshots and ObjectBox schema IDs are untouched.

Focused final command on JDK21: :app:testDebugUnitTest --tests com.ironlog.app.data.repository.ExerciseRepositoryIntegrityTest --no-daemon; BUILD SUCCESSFUL in34s, 3 tests /0 failures /0 errors. Full gates and signed release follow integration.

ObjectBox diagnostics: the initial red and first green run emitted cached read-cursor destruction warnings during JVM teardown. Final focused run after catalog read transaction emitted none; this is a narrow observation, not a claim that every repository/whole-suite teardown is fixed. Official lifecycle documentation identifies cached per-thread readers and closeThreadResources: https://objectbox.io/docfiles/java/current/io/objectbox/BoxStore.html . Retain the full-suite diagnostics investigation until the final gate.
Loading
Loading