diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 52c17c9..6880881 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -11829,6 +11829,34 @@ class TrainlogRepository( fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2) + private fun legacyProgramDigest(program: JSONObject): String { + // WHY: serializing and reparsing JSONObject changes integral doubles (for + // example 52.0 to 52), which changes a V1 revision's causal digest. + // CONTRACT: discard only V2 fields while retaining the parsed V1 values. + val legacy = JSONObject() + program.keys().forEach { key -> + when (key) { + "revision_sequence" -> Unit + "sessions" -> { + val rows = JSONArray() + val sessions = program.getJSONArray("sessions") + for (index in 0 until sessions.length()) { + val source = sessions.getJSONObject(index) + val row = JSONObject() + source.keys().forEach { sessionKey -> + if (sessionKey !in setOf("position", "current_for", "planning_state")) + row.put(sessionKey, source.get(sessionKey)) + } + rows.put(row) + } + legacy.put(key, rows) + } + else -> legacy.put(key, program.get(key)) + } + } + return causalDigest(legacy) + } + private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult { if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 || !jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.") @@ -12015,18 +12043,8 @@ class TrainlogRepository( if (existing?.revisionId == program.getString("revision_id")) { if (existing.digest == digest) { skipped++; continue } if (documentVersion != 2 || existing.version != 1 || - existing.digest != causalDigest(JSONObject(program.toString()).apply { - remove("revision_sequence") - getJSONArray("sessions").let { rows -> - for (sessionIndex in 0 until rows.length()) { - rows.getJSONObject(sessionIndex).apply { - remove("position") - remove("current_for") - remove("planning_state") - } - } - } - })) throw ProgramsImportInvalid("Program revision identity conflict.") + existing.digest != legacyProgramDigest(program)) + throw ProgramsImportInvalid("Program revision identity conflict.") } if (documentVersion == 2) { val activeProgramSession = db.rawQuery( diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt index 70b83e7..3863312 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt @@ -131,6 +131,30 @@ class ProgramsProjectionRepositoryTest { } } + @Test + fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() { + val name = "program-decimal-upgrade-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + try { + // The desktop JSON exporter writes SQLite REAL values as 40.0. + // JSONObject's own serialization writes the same value as 40. + val v1 = artifact(JSONArray().put(program())) + .replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0") + val v2 = artifactV2(program()) + .replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0") + assertTrue(v1.contains("\"target_weight_kg\":40.0")) + assertTrue(v2.contains("\"target_weight_kg\":40.0")) + assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1)) + assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2)) + assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!! + .sessions.single().currentFor) + assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2)) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + private fun preparationArtifact( includeProgramProvenance: Boolean = true, deliveryState: String = "pending",