Compare commits

..

17 commits

Author SHA1 Message Date
fy59
0262433615 docs(sync): document decoupled generation admission 2026-09-20 19:22:19 +02:00
fy59
ab3986200e feat(sync): separate full-generation Android trigger 2026-09-20 19:22:19 +02:00
fy59
eb6d70fb62 docs(sync): document Android conversation ownership 2026-09-20 17:54:47 +02:00
fy59
38d304d014 fix(android): prioritize explicit sync conversations 2026-09-20 17:54:43 +02:00
fy59
ca2ea9379b docs(sync): document MTP visibility guarantees 2026-09-20 15:30:06 +02:00
fy59
0e13c3947a fix(android): keep body metrics two-column on normal phones 2026-09-20 15:30:06 +02:00
fy59
60b08f225a fix(sync): throttle MTP polling and preserve timeout cause 2026-09-20 15:30:06 +02:00
fy59
a2eaf224e8 fix(sync): make Android generation publication visible to MTP 2026-09-20 15:30:00 +02:00
fy59
ba5021b436 fix(sync): retain strict V3 fallback publication 2026-09-20 14:40:59 +02:00
fy59
99e428c558 docs(sync): reconcile rollout readiness 2026-09-20 14:37:07 +02:00
fy59
2a68e5a505 docs(sync): document full-generation daily routing 2026-09-20 14:35:42 +02:00
fy59
1e9c8aa741 fix(sync): route Android requests through full generations 2026-09-20 14:35:42 +02:00
fy59
f27ef5af5f refactor(android): compact drafts and body measurement screens 2026-09-20 14:26:34 +02:00
fy59
53baa7ad24 chore(android): keep private candidates on 0.1.3 2026-09-20 13:29:58 +02:00
fy59
e41949557b docs(ui): document unified capture interface 2026-09-20 13:23:08 +02:00
fy59
d56bd3b54f feat(web): refine program progress presentation 2026-09-20 13:23:08 +02:00
fy59
cb4010dbd5 feat(android): unify capture-first mobile interface 2026-09-20 13:23:08 +02:00
56 changed files with 2773 additions and 546 deletions

View file

@ -2,8 +2,64 @@
## Unreleased — 0.1.3
- Decoupled the Android full-generation request from the strict legacy V3
compatibility signal. One explicit intent now publishes the dedicated
`trainlog-sync-full-generation-request-v1.json` channel first; the legacy
request is published with the same identity only after a fresh V3 snapshot
succeeds. The desktop daemon prioritizes this channel and durably
deduplicates a bounded set of request identities across both channels.
Development opened after the stable v0.1.2 release.
- Made explicit Android synchronization intent authoritative over automatic
background resumption. A process-wide conversation arbiter now yields an
older background wait before the foreground publishes its fresh request
UUID, and `NEW_EXPLICIT` ignores the request/run that existed before the
click. Background USB and Drive workers classify new requests, new
correlated remote evidence, legitimate handoff, and stale resumable state;
terminal runs are no longer retried every five seconds without new proof.
Durable generations are retained across yield and handoff, and desktop
request-ID deduplication remains strict. No schema, protocol, causal rule or
exchange format changed.
- Hardened direct-MTP full-generation conversations after a private Android
run was frozen before generation capture. Android now emits bounded
run/phase metadata, refreshes the user-enabled background listener before a
foreground request, and confirms MediaProvider/MTP visibility for immutable
generation objects before publishing their reference. Desktop MTP pulls are
throttled to one complete refresh every three seconds, keep a separate
30-second operation budget, and report expiration of the conversation as a
correlated protocol timeout instead of a misleading final one-second
transport timeout. Explicit requests continue to receive fresh UUIDs.
Mensurations now retains two compact columns at the deployed phone's 312 dp
content width, with a narrow/large-font fallback. Schemas, causal semantics,
generation formats, ACK formats and standalone V3 strictness are unchanged.
- Routed Android foreground and daemon-triggered daily synchronization through
the existing full-generation orchestrator whenever its trusted local
configuration is present. The standalone mobile V3 importer remains strict
and still rejects causally protected live replay; complete generations apply
tombstones before filtering dominated snapshot facts. No schema, exchange
format, causal rule, or synchronization protocol changed. Android backup
manifests now obtain `product_version` from installed package metadata
instead of retaining the historical `0.1.2` literal.
- Unified the Android capture interface around compact dark cards, action
tiles, restrained-radius button variants, wrapping selection grids, and
two-column performed-set entry. Home now focuses on session capture, recent
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
navigation was removed without changing stored data or exchange contracts.
Programs now show compact session metadata, completed progress, and explicit
start/resume/completed states using the existing schema-v25 durable Program
execution provenance. Web Program administration gained adaptive cards, a
visible progress bar, and green completed-session treatment while retaining
its revision-guarded trash confirmation and causal deletion behavior.
Android full lint remains at the exact ten-error `main` baseline (one
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
no rule. Private daily candidates retain development `versionName=0.1.3` and
may override only an increasing `versionCode` at build time.
- Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete

View file

@ -82,8 +82,6 @@ class DraftUiTestActivity : ComponentActivity() {
?: (loaded as?
ActiveDraftLoadResult.Loaded)
?.warning,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
onSession = {
when (
@ -102,9 +100,7 @@ class DraftUiTestActivity : ComponentActivity() {
}
},
onOpenExercises = {},
onOpenStatistics = {},
onBody = {},
onOpenLatestSession = {},
onSync = {},
)
}

View file

@ -26,7 +26,13 @@ internal data class VerifiedAndroidBackup(
)
/** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(private val context: Context) {
internal class AndroidBackupService(
private val context: Context,
private val productVersion: String =
checkNotNull(
context.packageManager.getPackageInfo(context.packageName, 0).versionName,
),
) {
companion object {
const val FORMAT = "trainlog-android-backup"
const val VERSION = 1
@ -140,7 +146,7 @@ internal class AndroidBackupService(private val context: Context) {
.put("version", VERSION)
.put("package", context.packageName)
.put("schema", schema)
.put("product_version", "0.1.2")
.put("product_version", productVersion)
.put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString())
.put(

View file

@ -291,10 +291,15 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
transport.pull(local)
stage = "coordinate"
val coordinator = SyncGenerationCoordinator(repository)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
coordinator.publishPeer(local)
stage = "peer-publication"
transport.pushPeer(local)
if (!coordinator.hasActionableRequest(local)) {
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NOT_ACTIONABLE ||
actionability == BackgroundRequestActionability.RESUMABLE_BUT_STALE
) {
if (coordinator.hasReplayableAcknowledgement(local)) {
stage = "acknowledgement-replay"
transport.pushAcknowledgement(local)
@ -322,8 +327,31 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
stage = "acknowledgement"
transport.pushAcknowledgement(local)
},
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
when (result) {
is ForegroundGenerationResult.Completed -> {
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(local, result.runId),
)
Result.success()
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Result.success()
is ForegroundGenerationResult.Superseded -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.success()
}
is ForegroundGenerationResult.Failed -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.retry()
}
}
} catch (error: Exception) {
/* SECURITY: stage and exception class are fixed application
* metadata. Provider messages may contain private URIs and are

View file

@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File =
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
"trainlog-sync-full-generation-request-v1.json" -> "FULL_GENERATION_SYNC_REQUEST"
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.media.MediaScannerConnection
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Confirms that durable external-storage files have reached Android's MTP index. */
internal fun interface MtpPublicationVisibility {
fun confirm(files: List<File>)
}
internal object ImmediateMtpPublicationVisibility : MtpPublicationVisibility {
override fun confirm(files: List<File>) = Unit
}
internal class AndroidMtpPublicationVisibility(context: Context) : MtpPublicationVisibility {
private val applicationContext = context.applicationContext
override fun confirm(files: List<File>) {
val paths = files.asSequence().filter(File::isFile).map { it.absolutePath }.distinct().toList()
if (paths.isEmpty()) return
val completed = CountDownLatch(paths.size)
/* WHY: direct File writes are durable before MediaProvider necessarily
* advertises them through Samsung's MTP database. CONTRACT: every
* immutable artifact is scanned before its mutable reference, and a
* publication is not reported complete until every callback arrives.
* INVARIANT: scanning changes transport visibility only; canonical
* names, bytes, generation identities and commit markers are intact. */
MediaScannerConnection.scanFile(
applicationContext,
paths.toTypedArray(),
Array(paths.size) { "application/json" },
) { _, _ -> completed.countDown() }
if (!completed.await(15, TimeUnit.SECONDS)) {
throw SyncGenerationException("timeout confirming MTP publication visibility")
}
}
}

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.data
import android.content.Context
internal enum class BackgroundRequestActionability {
NEW_REQUEST,
NEW_REMOTE_EVIDENCE,
ACTIVE_HANDOFF,
RESUMABLE_BUT_STALE,
NOT_ACTIONABLE,
}
internal data class BackgroundTerminalRun(
val runId: String,
val remoteEvidence: String,
)
/** One-entry durable suppression ledger for the current background request. */
internal class BackgroundSyncRunLedger(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun terminal(): BackgroundTerminalRun? {
val runId = preferences.getString(RUN_ID, null) ?: return null
return BackgroundTerminalRun(runId, preferences.getString(REMOTE_EVIDENCE, "").orEmpty())
}
fun recordTerminal(runId: String, remoteEvidence: String) {
/* CONTRACT: only the request currently present on the transport needs
* suppression. A replacement request has a distinct run_id and is
* immediately actionable, so this ledger remains permanently bounded
* to one record and is not a second synchronization database. */
preferences.edit()
.putString(RUN_ID, runId)
.putString(REMOTE_EVIDENCE, remoteEvidence)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-background-sync-terminal"
private const val RUN_ID = "run_id"
private const val REMOTE_EVIDENCE = "remote_evidence"
}
}

View file

@ -72,7 +72,11 @@ class SyncBackgroundService : Service() {
}
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
val coordinator = SyncGenerationCoordinator(
requireNotNull(repository),
AndroidMtpPublicationVisibility(applicationContext),
)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation
@ -81,8 +85,36 @@ class SyncBackgroundService : Service() {
* the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory)
if (coordinator.hasActionableRequest(directory)) {
coordinator.run(directory, Duration.ofMinutes(5))
val actionability =
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NEW_REQUEST ||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
) {
when (
val result = coordinator.run(
directory,
Duration.ofMinutes(5),
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
) {
is ForegroundGenerationResult.Completed ->
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(directory, result.runId),
)
is ForegroundGenerationResult.Failed ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
is ForegroundGenerationResult.Superseded ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Unit
}
}
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}

View file

@ -0,0 +1,116 @@
package com.labfytools.trainlog.data
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
internal enum class SyncConversationOwner {
FOREGROUND_EXPLICIT,
BACKGROUND_AUTOMATIC,
}
internal class SyncConversationYieldedException : Exception("conversation superseded by explicit sync")
/** Process-wide ownership authority for Android generation conversations. */
internal class SyncConversationArbiter {
private val lock = ReentrantLock()
private val ownershipChanged = lock.newCondition()
private var active: Lease? = null
private var pendingExplicit = 0
/**
* Announces user intent before the foreground service is refreshed.
*
* WHY: starting the service first allowed it to acquire the old binary
* lock and silently absorb the click. CONTRACT: an announcement prevents
* new background ownership and cooperatively supersedes an existing
* background lease. INVARIANT: ownership is still exclusive until that
* lease observes yield and releases in its finally block.
*/
fun announceExplicit(): ExplicitClaim = lock.withLock {
pendingExplicit += 1
active?.takeIf { it.owner == SyncConversationOwner.BACKGROUND_AUTOMATIC }
?.requestYield()
ownershipChanged.signalAll()
ExplicitClaim(this)
}
fun tryAcquireBackground(): Lease? = lock.withLock {
if (active != null || pendingExplicit > 0) return null
Lease(this, SyncConversationOwner.BACKGROUND_AUTOMATIC).also { active = it }
}
private fun acquireExplicit(claim: ExplicitClaim, timeout: Duration): Lease? {
return lock.withLock {
check(claim.announced)
var remaining = timeout.toNanos()
while (active != null) {
if (remaining <= 0L) {
releaseAnnouncement(claim)
return@withLock null
}
remaining = ownershipChanged.awaitNanos(remaining)
}
releaseAnnouncement(claim)
Lease(this, SyncConversationOwner.FOREGROUND_EXPLICIT).also { active = it }
}
}
private fun releaseAnnouncement(claim: ExplicitClaim) {
if (!claim.announced) return
claim.announced = false
pendingExplicit -= 1
check(pendingExplicit >= 0)
ownershipChanged.signalAll()
}
private fun release(lease: Lease) = lock.withLock {
if (active === lease) {
active = null
ownershipChanged.signalAll()
}
}
internal class ExplicitClaim internal constructor(private val arbiter: SyncConversationArbiter) :
AutoCloseable {
internal var announced = true
private var acquired = false
fun acquire(timeout: Duration): Lease? {
check(!acquired)
val lease = arbiter.acquireExplicit(this, timeout)
acquired = lease != null
return lease
}
override fun close() = arbiter.lock.withLock {
if (!acquired) arbiter.releaseAnnouncement(this)
}
}
internal class Lease internal constructor(
private val arbiter: SyncConversationArbiter,
val owner: SyncConversationOwner,
) : AutoCloseable {
@Volatile private var yieldRequested = false
@Volatile private var closed = false
internal fun requestYield() {
yieldRequested = true
}
fun throwIfYieldRequested() {
if (yieldRequested) throw SyncConversationYieldedException()
}
override fun close() {
if (closed) return
closed = true
arbiter.release(this)
}
}
companion object {
val process = SyncConversationArbiter()
}
}

View file

@ -1,5 +1,6 @@
package com.labfytools.trainlog.data
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
@ -12,21 +13,54 @@ import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data object Busy : ForegroundGenerationResult
data class Superseded(val runId: String?) : ForegroundGenerationResult
data class Cancelled(val runId: String?) : ForegroundGenerationResult
data class Failed(val message: String, val runId: String?) : ForegroundGenerationResult
}
data class Error(val message: String) : ForegroundGenerationResult
internal enum class SyncConversationIntent {
NEW_EXPLICIT,
RESUME_BACKGROUND,
}
internal fun interface SyncGenerationTrace {
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
}
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
override fun record(
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
) {
Log.i(
"TrainlogSyncGeneration",
listOfNotNull(
"run_id=${runId ?: "pending"}",
"phase=$phase",
artifact?.let { "artifact=$it" },
generationId?.let { "generation_id=$it" },
result?.let { "result=$it" },
).joinToString(" "),
)
}
}
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
) {
private val service = SyncGenerationService(repository)
companion object {
/* WHY: UI and background triggers share one repository protocol and
* must never create concurrent runs. CONTRACT: this process-wide lock
* guards only one bounded conversation; SQLite remains the durable
* replay authority after process restart. */
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
}
private fun phase(
runId: String?,
phase: String,
artifact: String? = null,
generationId: String? = null,
result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result)
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
@ -56,10 +90,10 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
}
}
internal fun hasActionableRequest(directory: File): Boolean {
private fun request(directory: File): JSONObject? {
val path = File(directory, "request-v1.json")
if (!path.isFile || path.length() !in 1..64 * 1024) return false
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
if (!path.isFile || path.length() !in 1..64 * 1024) return null
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
val runId = request.optString("run_id")
val desktopPeerId = request.optString("desktop_peer_id")
if (
@ -68,16 +102,64 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
request.optString("android_peer_id") != service.peerId()
) return false
if (resumableGeneration(runId, desktopPeerId) != null) return true
return !repository.inSyncGenerationTransaction { db ->
) return null
return request
}
private fun hasGeneration(runId: String): Boolean =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
internal fun remoteEvidence(directory: File, runId: String): String {
val evidence =
listOf(
Triple("desktop-consumption-ack-v1.json", "trainlog-sync-ack", 1),
Triple("desktop-generation-v1.json", "trainlog-sync-generation-reference", 1),
)
.mapNotNull { (name, format, version) ->
val path = File(directory, name)
if (!path.isFile || path.length() !in 1..64 * 1024) return@mapNotNull null
val raw = runCatching { path.readBytes() }.getOrNull() ?: return@mapNotNull null
val value = runCatching { JSONObject(raw.toString(Charsets.UTF_8)) }.getOrNull()
?: return@mapNotNull null
if (
value.optString("format") != format ||
value.optInt("version", -1) != version ||
value.optString("run_id") != runId
) return@mapNotNull null
"$name:${java.security.MessageDigest.getInstance("SHA-256").digest(raw)
.joinToString("") { "%02x".format(it) }}"
}
return evidence.sorted().joinToString("|")
}
internal fun classifyBackgroundRequest(
directory: File,
terminal: BackgroundTerminalRun?,
): BackgroundRequestActionability {
val request = request(directory) ?: return BackgroundRequestActionability.NOT_ACTIONABLE
val runId = request.getString("run_id")
val desktopPeerId = request.getString("desktop_peer_id")
val resumable = resumableGeneration(runId, desktopPeerId) != null
if (terminal?.runId == runId) {
val currentEvidence = remoteEvidence(directory, runId)
return if (currentEvidence.isNotEmpty() && currentEvidence != terminal.remoteEvidence)
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE
else if (resumable) BackgroundRequestActionability.RESUMABLE_BUT_STALE
else BackgroundRequestActionability.NOT_ACTIONABLE
}
if (!hasGeneration(runId)) return BackgroundRequestActionability.NEW_REQUEST
if (resumable) return BackgroundRequestActionability.ACTIVE_HANDOFF
return BackgroundRequestActionability.NOT_ACTIONABLE
}
internal fun currentRequestRunId(directory: File): String? =
request(directory)?.optString("run_id")
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
val request =
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
@ -229,9 +311,12 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* an identical canonical descriptor is already a complete durable
* publication. INVARIANT: changed or unreadable bytes still use the
* crash-safe replacement path above. */
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
visibility.confirm(listOf(path))
return peer
}
publish(path, descriptor)
visibility.confirm(listOf(path))
return peer
}
@ -250,8 +335,10 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
deadline: Long,
predicate: (JSONObject) -> Boolean,
pump: (() -> Unit)? = null,
lease: SyncConversationArbiter.Lease,
): ByteArray {
while (true) {
lease.throwIfYieldRequested()
if (path.isFile && path.length() <= 64 * 1024) {
try {
val raw = path.readBytes()
@ -275,33 +362,57 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
): ForegroundGenerationResult {
if (!conversationLock.tryLock()) {
return ForegroundGenerationResult.Error("Synchronization already in progress.")
val claim =
if (intent == SyncConversationIntent.NEW_EXPLICIT)
explicitClaim ?: arbiter.announceExplicit()
else null
val lease = try {
claim?.acquire(timeout) ?: if (claim == null) arbiter.tryAcquireBackground() else null
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
claim?.close()
return ForegroundGenerationResult.Cancelled(null)
}
if (lease == null) {
claim?.close()
return ForegroundGenerationResult.Busy
}
var currentRunId: String? = null
return try {
val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json")
val baselineRunId = request(directory)?.optString("run_id")
/* WHY: trainlog-syncd is intentionally driven by the established
* Android request artifact, not by polling generation internals.
* CONTRACT: each explicit foreground attempt publishes the peer
* identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke()
afterPeerPublication?.let {
it.invoke()
phase(
null,
"full_generation_trigger_published",
FULL_GENERATION_REQUEST_NAME,
)
}
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
(resumableGeneration(runId) != null ||
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
})
}, pollTransport)
runId.startsWith("sy_") && when (intent) {
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
SyncConversationIntent.RESUME_BACKGROUND ->
resumableGeneration(runId) != null || !hasGeneration(runId)
}
}, pollTransport, lease)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
val runId = request.getString("run_id")
currentRunId = runId
phase(runId, "generation_request_observed", "request-v1.json")
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val archiveAckRaw =
@ -313,6 +424,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
it.optString("android_peer_id") == peer
},
pollTransport,
lease,
)
reconcileArchiveAcknowledgements(
archiveAckRaw,
@ -320,6 +432,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
peer,
request.getString("desktop_peer_id"),
)
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
@ -327,6 +440,8 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
phase(runId, "generation_capture_started")
lease.throwIfYieldRequested()
val captured =
resumableGeneration(
request.getString("run_id"),
@ -336,9 +451,22 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
lease.throwIfYieldRequested()
phase(runId, "generation_captured", generationId = captured.generationId)
val published = service.publish(captured, File(directory, "android-objects"))
visibility.confirm(
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
)
lease.throwIfYieldRequested()
phase(
runId,
"generation_objects_published",
published.relativeTo(directory).path,
captured.generationId,
)
val generationReference = File(directory, "android-generation-v1.json")
publish(
File(directory, "android-generation-v1.json"),
generationReference,
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
@ -348,7 +476,15 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
visibility.confirm(listOf(generationReference))
phase(
runId,
"generation_reference_published",
generationReference.name,
captured.generationId,
)
afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
@ -356,13 +492,20 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId
}, pollTransport)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId
}, pollTransport)
}, pollTransport, lease)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
phase(
runId,
"desktop_generation_observed",
"desktop-generation-v1.json",
reference.optString("generation_id"),
)
/* WHY: non-atomic transports can expose a newly downloaded
* reference locally before the corresponding immutable objects
* have reached this process. CONTRACT: correlation validates the
@ -373,9 +516,30 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
pollTransport?.invoke()
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
phase(
runId,
"desktop_generation_consumed",
generationId = reference.optString("generation_id"),
)
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
publish(acknowledgementPath, acknowledgement)
visibility.confirm(listOf(acknowledgementPath))
phase(
runId,
"android_ack_published",
acknowledgementPath.name,
reference.optString("generation_id"),
)
afterAcknowledgement?.invoke()
phase(runId, "completed", result = "completed")
ForegroundGenerationResult.Completed(captured.runId)
} catch (_: SyncConversationYieldedException) {
phase(currentRunId, "superseded", result = "foreground_priority")
ForegroundGenerationResult.Superseded(currentRunId)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
phase(currentRunId, "cancelled", result = "interrupted")
ForegroundGenerationResult.Cancelled(currentRunId)
} catch (error: Exception) {
if (error.message == "generation retention capacity exhausted") {
try {
@ -391,14 +555,26 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("action", "Archive acknowledged generations, then retry explicitly.")
.toString(),
)
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
} catch (_: Exception) {
// Preserve the original local failure when error reporting
// cannot itself be durably published.
}
}
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
phase(
runCatching {
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
}.getOrNull(),
"error",
result = error.javaClass.simpleName,
)
ForegroundGenerationResult.Failed(
error.message ?: "generation synchronization failed",
currentRunId,
)
} finally {
conversationLock.unlock()
lease.close()
claim?.close()
}
}
}

View file

@ -13,6 +13,23 @@ import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal const val FULL_GENERATION_REQUEST_NAME =
"trainlog-sync-full-generation-request-v1.json"
internal const val LEGACY_SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"
internal data class SyncRequestIntent(
val requestId: String,
val requestedAt: String,
) {
fun payload(): String =
JSONObject()
.put("format", "trainlog-sync-request")
.put("version", 1)
.put("request_id", requestId)
.put("requested_at", requestedAt)
.toString()
}
sealed interface SyncRequestResult {
/** The full-generation coordinator completed both durable peer ACKs. */
data class Completed(
@ -33,13 +50,16 @@ sealed interface SyncRequestResult {
class SyncRequestOutbox private constructor(
private val publisher: DirectExchangePublisher,
private val visibility: MtpPublicationVisibility,
) {
constructor(context: Context) : this(
DirectExchangePublisher { directExchangeDirectory(context) },
AndroidMtpPublicationVisibility(context),
)
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
DirectExchangePublisher(directoryProvider),
ImmediateMtpPublicationVisibility,
)
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
@ -57,37 +77,42 @@ class SyncRequestOutbox private constructor(
return SyncRequestResult.Unsupported
}
val requestId =
"sr_" +
UUID.randomUUID()
.toString()
val intent = newIntent()
return publishLegacy(snapshot, intent)
}
val payload =
JSONObject()
.put(
"format",
"trainlog-sync-request",
)
.put(
"version",
1,
)
.put(
"request_id",
requestId,
)
.put(
"requested_at",
OffsetDateTime.now()
.toString(),
)
.toString()
internal fun newIntent(): SyncRequestIntent =
SyncRequestIntent(
requestId = "sr_${UUID.randomUUID()}",
requestedAt = OffsetDateTime.now().toString(),
)
val displayName =
"trainlog-sync-request-v1.json"
val error = snapshot.writeJson(displayName, payload)
internal fun publishFullGeneration(
snapshot: DirectExchangeSnapshot,
intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, FULL_GENERATION_REQUEST_NAME, intent)
internal fun publishLegacy(
snapshot: DirectExchangeSnapshot,
intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, LEGACY_SYNC_REQUEST_NAME, intent)
private fun publish(
snapshot: DirectExchangeSnapshot,
displayName: String,
intent: SyncRequestIntent,
): SyncRequestResult {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return SyncRequestResult.Unsupported
}
val error = snapshot.writeJson(displayName, intent.payload())
return if (error == null) {
SyncRequestResult.Requested(requestId)
try {
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
SyncRequestResult.Requested(intent.requestId)
} catch (error: Exception) {
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
}
} else {
SyncRequestResult.Error(error)
}

View file

@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -32,6 +33,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import com.labfytools.trainlog.R
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.launch
@ -41,7 +43,6 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@ -59,8 +60,7 @@ fun AndroidAppShell(
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
AppSection.SETTINGS -> R.string.nav_settings
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
@ -71,7 +71,7 @@ fun AndroidAppShell(
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
})
@ -107,7 +107,16 @@ fun AndroidAppShell(
onOpenSection(section)
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = colors.surfaceAlt,
selectedIconColor = colors.accent,
selectedTextColor = colors.text,
unselectedContainerColor = colors.mantle,
unselectedIconColor = colors.muted,
unselectedTextColor = colors.muted,
),
)
}
}

View file

@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
EQUIPMENT, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -34,9 +34,8 @@ sealed interface AppRoute {
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
}
@ -46,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}

View file

@ -8,6 +8,13 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -89,148 +96,74 @@ fun BodyScreen(
TrainlogFrame(
title = strings.getString(R.string.general)
) {
BodyMetricField(
label = strings.getString(R.string.weight),
unit = "kg",
value = bodyWeight,
onValueChange = {
bodyWeight = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.neck),
unit = "cm",
value = neck,
onValueChange = {
neck = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.shoulders),
unit = "cm",
value = shoulders,
onValueChange = {
shoulders = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.chest),
unit = "cm",
value = chest,
onValueChange = {
chest = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.waist),
unit = "cm",
value = waist,
onValueChange = {
waist = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.hips),
unit = "cm",
value = hips,
onValueChange = {
hips = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
bodyWeight = it
message = null
},
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
neck = it
message = null
},
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
shoulders = it
message = null
},
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
chest = it
message = null
},
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
waist = it
message = null
},
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
hips = it
message = null
},
),
)
}
TrainlogFrame(
title = strings.getString(R.string.limbs)
) {
BodyMetricField(
label = strings.getString(R.string.left_arm),
unit = "cm",
value = leftArm,
onValueChange = {
leftArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_arm),
unit = "cm",
value = rightArm,
onValueChange = {
rightArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_forearm),
unit = "cm",
value = leftForearm,
onValueChange = {
leftForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_forearm),
unit = "cm",
value = rightForearm,
onValueChange = {
rightForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_thigh),
unit = "cm",
value = leftThigh,
onValueChange = {
leftThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_thigh),
unit = "cm",
value = rightThigh,
onValueChange = {
rightThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_calf),
unit = "cm",
value = leftCalf,
onValueChange = {
leftCalf = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_calf),
unit = "cm",
value = rightCalf,
onValueChange = {
rightCalf = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
leftArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
rightArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
leftForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
rightForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
leftThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
rightThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
leftCalf = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
rightCalf = it
message = null
},
),
)
}
@ -426,12 +359,14 @@ private fun BodyMetricField(
unit: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TrainlogInputField(
label = "$label ($unit)",
label = label,
value = value,
onValueChange =
onValueChange,
modifier = modifier,
keyboardOptions =
KeyboardOptions(
keyboardType =
@ -439,9 +374,51 @@ private fun BodyMetricField(
imeAction =
ImeAction.Next,
),
suffix = unit,
compact = true,
)
}
internal data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
val onValueChange: (String) -> Unit,
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
val twoCompactFieldsDp = 2 * 140f + 10f
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
}
@Composable
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
/* WHY: this 360 dp device has 312 dp after screen padding; the old
* 330 dp threshold therefore stacked fields that need only 140 dp.
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
* unless width or accessibility font scaling makes them unreadable. */
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowItems.forEach { item ->
BodyMetricField(
item.label,
item.unit,
item.value,
item.onValueChange,
Modifier.weight(1f),
)
}
repeat(columns - rowItems.size) { androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) }
}
}
}
}
}
private sealed interface MetricParse {
data object Empty :
MetricParse

View file

@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -852,7 +855,11 @@ private fun TrainlogChoiceGroup(
),
)
content()
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { content() }
}
}
@ -869,15 +876,15 @@ private fun TrainlogChoice(
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.widthIn(min = 132.dp)
.heightIn(min = 48.dp)
.background(
if (selected) {
colors.surfaceAlt
} else {
colors.surface
}
},
androidx.compose.material3.MaterialTheme.shapes.medium,
)
.semantics { this.selected = selected }
.clickable(

View file

@ -12,6 +12,7 @@ import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import java.time.OffsetDateTime
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
@ -133,7 +134,8 @@ internal fun formatStartedAt(
internal fun formatDate(value: String): String {
val locale = presentationLocale()
val pattern = uiString(R.string.date_pattern)
return runCatching {
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
}.getOrElse { value.take(10) }
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
.recoverCatching { OffsetDateTime.parse(value).format(formatter) }
.getOrElse { value.take(10) }
}

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@ -52,8 +51,6 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
fun HomeScreen(
activeDraft: ActiveSessionDraft?,
draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val locale = presentationLocale()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame(strings.getString(R.string.home_session)) {
@ -87,17 +79,7 @@ fun HomeScreen(
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
}
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
latestSession?.let { session ->
TrainlogFrame(strings.getString(R.string.latest_session)) {
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -119,7 +101,6 @@ fun HomeScreen(
internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
}
selected?.let { zone ->
Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
Modifier.fillMaxWidth().padding(top = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
@ -179,7 +162,6 @@ internal fun BodyZoneHomeSection(
)
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
}
}
}

View file

@ -8,6 +8,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
val colors = LocalTrainlogColors.current
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
TrainlogFrame(program.title, active = program.state == "active") {
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
val completed = detail?.sessions?.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
} ?: 0
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
program.title,
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = colors.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(
if (program.state == "active") strings.getString(R.string.program_active)
else strings.getString(R.string.program_archived),
program.state == "active",
)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
),
).joinToString(" · "),
)
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
else LocalTrainlogColors.current.surface,
MaterialTheme.shapes.medium,
).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val stateLabel = when (session.executionState) {
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
}
TrainlogInfo(strings.getString(stateLabel))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${index + 1}. ${session.title}",
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
@ -141,3 +193,18 @@ fun ProgramDetailScreen(
}
}
}
/** Presentation-only state badge; execution truth remains repository-owned. */
@Composable
private fun ProgramStateBadge(label: String, positive: Boolean) {
val colors = LocalTrainlogColors.current
Text(
label,
modifier = Modifier.background(
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
MaterialTheme.shapes.small,
).padding(horizontal = 8.dp, vertical = 4.dp),
color = if (positive) colors.success else colors.muted,
style = MaterialTheme.typography.labelMedium,
)
}

View file

@ -14,12 +14,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
@ -36,6 +41,8 @@ import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@ -93,21 +100,14 @@ fun AiSessionDraftsScreen(
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
preparations.forEach { preparation ->
TrainlogFrame(preparation.title, active = true) {
TrainlogInfo(listOfNotNull(
strings.getString(R.string.manual_preparation),
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
).joinToString(" · "))
preparation.notes?.let { TrainlogInfo(it) }
preparation.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) {
DraftSummaryCard(
title = preparation.title,
provenance = strings.getString(R.string.manual_preparation),
plannedFor = preparation.plannedFor,
notes = preparation.notes,
entries = preparation.entries,
startDescription = strings.getString(R.string.start_preparation_description),
onStart = {
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
StartAiSessionDraftResult.ExistingActiveDraft ->
@ -115,24 +115,18 @@ fun AiSessionDraftsScreen(
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
},
)
}
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
TrainlogInfo(listOfNotNull(
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
).joinToString(" · "))
draft.notes?.let { TrainlogInfo(it) }
draft.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
DraftSummaryCard(
title = draft.title ?: strings.getString(R.string.session_proposal),
provenance = strings.getString(R.string.session_proposal),
plannedFor = draft.plannedFor,
notes = draft.notes,
entries = draft.entries,
startDescription = strings.getString(R.string.start_proposal_description),
onStart = {
when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> {
onPendingChanged()
@ -147,15 +141,24 @@ fun AiSessionDraftsScreen(
}
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
footer = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
fullWidth = false,
onClick = {
pendingDeletion = draft.draftId to
(draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
)
}
}
pendingDeletion?.let { (draftId, title) ->
@ -181,6 +184,138 @@ fun AiSessionDraftsScreen(
}
}
/** Compact presentation of an inert preparation; starting remains caller-owned. */
@Composable
private fun DraftSummaryCard(
title: String,
provenance: String,
plannedFor: String?,
notes: String?,
entries: List<SessionExerciseDraft>,
startDescription: String,
onStart: () -> Unit,
footer: (@Composable () -> Unit)? = null,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
TrainlogCompactCard {
androidx.compose.material3.Text(
title,
color = colors.text,
style = TrainlogTypography.section,
fontWeight = FontWeight.Bold,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TrainlogMetaBadge(provenance, Modifier.weight(1f))
TrainlogMetaBadge(
strings.resources.getQuantityString(
R.plurals.exercise_count,
entries.size,
entries.size,
),
)
}
plannedFor?.let {
androidx.compose.material3.Text(
strings.getString(R.string.planned_for, formatDate(it)),
color = colors.muted,
style = TrainlogTypography.small,
)
}
notes?.takeIf { it.isNotBlank() }?.let {
androidx.compose.material3.Text(
it,
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
entries.forEach { DraftExerciseRow(it) }
}
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.start),
onStart,
Modifier.widthIn(min = 128.dp),
style = TrainlogButtonStyle.SUCCESS,
)
androidx.compose.material3.Text(
startDescription,
modifier = Modifier.weight(1f),
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
footer?.invoke()
}
}
}
@Composable
private fun DraftExerciseRow(entry: SessionExerciseDraft) {
val colors = LocalTrainlogColors.current
val plan = checkNotNull(entry.plan)
Column(
Modifier.fillMaxWidth()
.background(colors.background, androidx.compose.material3.MaterialTheme.shapes.small)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
androidx.compose.material3.Text(
entry.exercise.name,
color = colors.text,
style = TrainlogTypography.small,
fontWeight = FontWeight.SemiBold,
)
androidx.compose.material3.Text(
draftPlanSummary(plan.sets, plan.reps, plan.durationSeconds, plan.weightKg, plan.restSeconds),
color = colors.muted,
style = TrainlogTypography.small,
)
}
}
@Composable
private fun draftPlanSummary(
sets: Int,
reps: Int?,
durationSeconds: Int?,
weightKg: Double?,
restSeconds: Int,
): String {
val strings = localizedContext()
val metric = when {
reps != null -> "$sets × $reps"
durationSeconds != null && sets > 0 -> "$sets × ${humanDuration(durationSeconds)}"
durationSeconds != null -> humanDuration(durationSeconds)
else -> "—"
}
return buildList {
add(metric)
weightKg?.let { add("${it.toString().trimEnd('0').trimEnd('.')} kg") }
if (restSeconds > 0) add(strings.getString(R.string.draft_rest_short, restSeconds))
}.joinToString(" · ")
}
@Composable
private fun humanDuration(seconds: Int): String {
val strings = localizedContext()
return if (seconds % 60 == 0) {
val minutes = seconds / 60
strings.resources.getQuantityString(R.plurals.duration_minutes_value, minutes, minutes)
} else {
strings.resources.getQuantityString(R.plurals.duration_seconds_value, seconds, seconds)
}
}
@Composable
fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current
@ -188,13 +323,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
val locale = presentationLocale()
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
androidx.compose.foundation.layout.Column(
Modifier.fillMaxWidth().padding(bottom = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
) {
androidx.compose.material3.Text(
max.exerciseName,
color = colors.text,
style = com.labfytools.trainlog.ui.theme.TrainlogTypography.section,
)
androidx.compose.foundation.layout.Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
androidx.compose.material3.Text("$weight kg", color = colors.warning)
androidx.compose.material3.Text(formatDate(max.startedAt), color = colors.muted)
}
max.equipmentDisplayName?.let {
TrainlogInfo(strings.getString(R.string.equipment_value, it), colors.muted)
}
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@ -196,69 +197,34 @@ fun SessionScreen(
TrainlogFrame(
title = strings.getString(R.string.session_type)
) {
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
strings.getString(R.string.training_selected)
} else {
strings.getString(R.string.training_unselected)
},
description =
strings.getString(R.string.normal_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
colors.success
} else {
colors.muted
},
onClick = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val training = currentDraft.sessionType == SessionType.TRAINING
TrainlogButton(
label = strings.getString(R.string.training),
modifier = Modifier.weight(1f),
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.TRAINING
),
currentDraft.copy(sessionType = SessionType.TRAINING),
null,
)
},
)
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
strings.getString(R.string.max_test_selected)
} else {
strings.getString(R.string.max_test_unselected)
},
description =
strings.getString(R.string.max_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.muted
},
onClick = {
)
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
TrainlogButton(
label = strings.getString(R.string.session_max_test),
modifier = Modifier.weight(1f),
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.MAX_TEST
),
currentDraft.copy(sessionType = SessionType.MAX_TEST),
null,
)
},
)
},
)
}
}
ExercisePicker(
@ -606,7 +572,7 @@ fun SessionScreen(
colors.success
} else {
colors.error
},
}
)
}
}
@ -1151,54 +1117,78 @@ private fun SessionExerciseForm(
strings.getString(R.string.field_weight)
}
setRows.forEachIndexed { index, row ->
TrainlogInfo(
text = strings.getString(R.string.set_number, index + 1),
color = colors.accent,
)
SessionNumberField(
label = strings.getString(R.string.set_reps_field, index + 1),
value = row.repsText,
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
Column(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(10.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicText(
text = strings.getString(R.string.set_number, index + 1),
modifier = Modifier.padding(vertical = 12.dp),
style = TrainlogTypography.small.copy(
color = colors.accent,
fontWeight = FontWeight.Bold,
),
)
},
)
SessionNumberField(
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
value = row.weightText,
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
accent = colors.error,
onClick = { pendingSetRemoval = index },
)
},
)
TrainlogDeleteButton(
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
onClick = {
pendingSetRemoval = index
},
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SessionNumberField(
label = strings.getString(R.string.field_reps),
value = row.repsText,
modifier = Modifier.weight(1f),
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
SessionNumberField(
label = loadLabel,
value = row.weightText,
modifier = Modifier.weight(1f),
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
}
}
}
TrainlogAction(
label = strings.getString(R.string.add_set_plain),
@ -1608,6 +1598,7 @@ private fun SessionNumberField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
testTag: String? = null,
decimal: Boolean = false,
) {
@ -1623,6 +1614,7 @@ private fun SessionNumberField(
} else {
KeyboardOptions(keyboardType = KeyboardType.Number)
},
modifier = modifier,
)
}

View file

@ -29,11 +29,16 @@ import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.BackgroundSyncRunLedger
import com.labfytools.trainlog.data.SyncConversationArbiter
import com.labfytools.trainlog.data.SyncConversationIntent
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.DriveSyncSettings
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
import com.labfytools.trainlog.data.MtpPublicationVisibility
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -49,26 +54,35 @@ internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
explicitClaim: SyncConversationArbiter.ExplicitClaim =
SyncConversationArbiter.process.announceExplicit(),
terminalLedger: BackgroundSyncRunLedger? = null,
): SyncRequestResult = withContext(Dispatchers.IO) {
val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json")
if (optIn.isFile) {
val enabled = try {
val value = org.json.JSONObject(optIn.readText())
value.keys().asSequence().toSet() == setOf("format", "version", "enabled") &&
value.optString("format") == "trainlog-sync-generation-opt-in" &&
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
} catch (_: Exception) { false }
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
}
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
try {
val directory = canonicalExchangeDirectory()
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository).run(
canonicalExchangeDirectory(),
val result = coordinator.run(
directory,
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
throw IllegalStateException(opened.message)
}
val snapshot =
(opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready)
.snapshot
val requestIntent = requestOutbox.newIntent()
/* WHY: a strict V3 snapshot cannot represent causal
* tombstones, but the complete generation can. CONTRACT:
* publish and confirm the modern trigger before attempting
* optional legacy compatibility. INVARIANT: an old desktop
* sees this intent only after a fresh V3 bundle succeeds. */
when (
val request =
requestOutbox.publishFullGeneration(snapshot, requestIntent)
) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
@ -76,13 +90,67 @@ internal suspend fun publishBundleAndRequest(
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
when (exporter.exportMobileBundle(snapshot)) {
is SyncExportResult.Exported ->
when (requestOutbox.publishLegacy(snapshot, requestIntent)) {
is SyncRequestResult.Requested ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_published",
)
else ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
SyncExportResult.Unsupported,
is SyncExportResult.Error ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = explicitClaim,
)
) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
ForegroundGenerationResult.Busy ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Superseded ->
SyncRequestResult.Error("sync_interrupted").also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
is ForegroundGenerationResult.Cancelled ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Failed ->
SyncRequestResult.Error(result.message).also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
}
} finally {
explicitClaim.close()
}
}
/* Compatibility tooling keeps the standalone V3 publisher strict and
* available, but production Android triggers no longer select this path. */
internal suspend fun publishLegacyBundleAndRequest(
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
@ -90,9 +158,7 @@ internal suspend fun publishBundleAndRequest(
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error(
"prepare:${publication.message}",
)
is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
is SyncExportResult.Exported -> {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
@ -138,8 +204,11 @@ fun SyncScreen(
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
}.joinToString(", ")
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
fun visibleError(raw: String): String = when {
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
raw.startsWith("prepare:") ->
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
else -> raw
}
var status by
@ -384,10 +453,27 @@ fun SyncScreen(
* published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */
/* WHY: Samsung may freeze the activity after USB mode
* changes or a permission surface opens. CONTRACT:
* announce explicit priority before waking the service.
* The service may keep Android alive, but cannot become
* conversation owner while this claim is pending. */
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
syncRunning = true
status = strings.getString(R.string.sync_running)
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
when (
val result = publishBundleAndRequest(
repository,
exporter,
requestOutbox,
AndroidMtpPublicationVisibility(context),
explicitClaim,
BackgroundSyncRunLedger(context),
)
) {
is SyncRequestResult.Completed -> {
// The generation coordinator has already consumed the
// desktop return and published its durable ACK. Waiting
@ -411,6 +497,7 @@ fun SyncScreen(
}
}
} finally {
explicitClaim.close()
syncRunning = false
}
}

View file

@ -93,7 +93,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
when (val route = navigation.route) {
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(),
::openManualSession,
{ zoneId ->
@ -102,8 +101,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
exerciseState.searchQuery.value = ""
open(AppRoute.Exercises)
},
{ open(AppRoute.Statistics) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
@ -141,7 +139,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -164,7 +166,7 @@ fun TrainlogAction(
.padding(vertical = 3.dp)
.heightIn(min = 48.dp)
.height(IntrinsicSize.Min)
.background(Color.Transparent)
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
) {
Column(
@ -199,6 +201,46 @@ fun TrainlogAction(
}
}
/** Presentation-only surface for dense object summaries and repeated rows. */
@Composable
fun TrainlogCompactCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalTrainlogColors.current
val interaction = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)
Column(
modifier
.fillMaxWidth()
.background(colors.surface, MaterialTheme.shapes.medium)
.then(interaction)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
content = content,
)
}
/** Small non-interactive metadata token; business state remains caller-owned. */
@Composable
fun TrainlogMetaBadge(
text: String,
modifier: Modifier = Modifier,
accent: Color? = null,
) {
val colors = LocalTrainlogColors.current
Text(
text = text,
modifier = modifier.background(colors.surfaceAlt, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = accent ?: colors.text,
style = TrainlogTypography.small,
maxLines = 1,
)
}
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */
@Composable
fun TrainlogIconAction(
@ -225,11 +267,13 @@ fun TrainlogDeleteButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fullWidth: Boolean = true,
) {
val colors = LocalTrainlogColors.current
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
@ -240,9 +284,7 @@ fun TrainlogDeleteButton(
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(label)
}
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
if (description.isNotBlank()) {
BasicText(
description,
@ -270,7 +312,7 @@ fun TrainlogActionTile(
Column(
modifier
.heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp))
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@ -291,13 +333,33 @@ fun TrainlogButton(
containerColor: Color? = null,
maxLines: Int = 1,
enabled: Boolean = true,
style: TrainlogButtonStyle = TrainlogButtonStyle.PRIMARY,
) {
val colors = LocalTrainlogColors.current
val resolvedContainer = containerColor ?: when (style) {
TrainlogButtonStyle.PRIMARY -> colors.accent
TrainlogButtonStyle.SECONDARY -> colors.surfaceAlt
TrainlogButtonStyle.SUCCESS -> colors.success
TrainlogButtonStyle.DESTRUCTIVE -> colors.error
TrainlogButtonStyle.GHOST -> Color.Transparent
}
val resolvedContent = when (style) {
TrainlogButtonStyle.PRIMARY, TrainlogButtonStyle.SUCCESS,
TrainlogButtonStyle.DESTRUCTIVE -> colors.crust
TrainlogButtonStyle.SECONDARY, TrainlogButtonStyle.GHOST -> colors.text
}
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
?: ButtonDefaults.buttonColors(),
shape = MaterialTheme.shapes.medium,
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
colors = ButtonDefaults.buttonColors(
containerColor = resolvedContainer,
contentColor = resolvedContent,
disabledContainerColor = colors.surfaceAlt,
disabledContentColor = colors.muted,
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) {
icon?.let {
@ -334,12 +396,15 @@ fun TrainlogInputField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions =
KeyboardOptions.Default,
testTag: String? = null,
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
suffix: String? = null,
compact: Boolean = false,
) {
val colors =
LocalTrainlogColors.current
@ -350,8 +415,7 @@ fun TrainlogInputField(
}
Column(
modifier =
Modifier.padding(bottom = 10.dp)
modifier = modifier.padding(bottom = 10.dp)
) {
BasicText(
text = label.uppercase(),
@ -395,7 +459,7 @@ fun TrainlogInputField(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.heightIn(min = if (compact) 44.dp else 48.dp)
.onFocusChanged {
focused = it.isFocused
}
@ -406,11 +470,22 @@ fun TrainlogInputField(
Modifier.testTag(testTag)
}
)
.background(colors.surface)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(
horizontal = 12.dp,
vertical = 10.dp,
horizontal = if (compact) 10.dp else 12.dp,
vertical = if (compact) 8.dp else 10.dp,
),
decorationBox = { innerTextField ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.weight(1f)) { innerTextField() }
suffix?.let {
BasicText(it, style = TrainlogTypography.small.copy(color = colors.muted))
}
}
},
)
Box(

View file

@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
data class TrainlogColors(
val background: Color,
@ -110,6 +113,19 @@ fun TrainlogTheme(
error = TrainlogDarkColors.error,
)
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
MaterialTheme(colorScheme = scheme, content = content)
/* INVARIANT: all Material controls share the same restrained corner
* language. Screen code may change size, but must not reintroduce the
* former pill-shaped action hierarchy. */
MaterialTheme(
colorScheme = scheme,
shapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(14.dp),
),
content = content,
)
}
}

View file

@ -94,6 +94,7 @@
<string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
<string name="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</string>
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
@ -334,7 +335,8 @@
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
<string name="start_preparation_description">Creates a protected active session.</string>
<string name="draft_rest_short">rest %1$d s</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>

View file

@ -94,6 +94,7 @@
<string name="sync_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</string>
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
@ -334,7 +335,8 @@
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
<string name="start_preparation_description">Crée une séance en cours protégée.</string>
<string name="draft_rest_short">repos %1$d s</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>

View file

@ -12,6 +12,8 @@ import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@ -24,6 +26,30 @@ import org.robolectric.annotation.Config
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun manifestUsesInjectedCanonicalProductVersion() {
val name = "backup-version-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val output = ByteArrayOutputStream()
val service = AndroidBackupService(context, "0.1.3-test")
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
var manifest: JSONObject? = null
ZipInputStream(ByteArrayInputStream(output.toByteArray())).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.name == "manifest.json") {
manifest = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
}
}
}
assertEquals("0.1.3-test", checkNotNull(manifest).getString("product_version"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db"

View file

@ -0,0 +1,251 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.nio.file.Files
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncConversationOwnershipTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun request(root: File, runId: String, androidPeer: String, desktopPeer: String) {
File(root, "request-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-generation-request")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", androidPeer)
.put("desktop_peer_id", desktopPeer)
.toString()
)
}
private fun archiveAcknowledgements(
root: File,
runId: String,
androidPeer: String,
desktopPeer: String,
) {
File(root, "desktop-archive-acknowledgements-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-archive-acknowledgements")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", androidPeer)
.put("desktop_peer_id", desktopPeer)
.put("acknowledgements", JSONArray())
.toString()
)
}
@Test
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-ownership-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val oldObserved = CountDownLatch(1)
val capturedRuns = mutableListOf<String>()
val desktopPeer = "peer_${UUID.randomUUID()}"
val oldRun = "sy_${UUID.randomUUID()}"
val newRun = "sy_${UUID.randomUUID()}"
val service = SyncGenerationService(repository)
val background =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
if (phase == "generation_request_observed" && runId == oldRun) oldObserved.countDown()
},
)
val foreground =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
if (phase == "generation_captured") synchronized(capturedRuns) {
capturedRuns += checkNotNull(runId)
}
},
)
val executor = java.util.concurrent.Executors.newFixedThreadPool(2)
try {
request(root, oldRun, service.peerId(), desktopPeer)
File(root, "trainlog-sync-request-v1.json").writeText(
"""{"format":"trainlog-sync-request","version":1,"request_id":"sr_old"}"""
)
val old = executor.submit<ForegroundGenerationResult> {
background.run(root, Duration.ofSeconds(3))
}
assertTrue(oldObserved.await(2, TimeUnit.SECONDS))
val claim = arbiter.announceExplicit()
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(root) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(root) }
var freshRequestId = ""
val explicit = executor.submit<ForegroundGenerationResult> {
foreground.run(
root,
Duration.ofMillis(700),
afterPeerPublication = {
freshRequestId =
(outbox.requestSync(snapshot) as SyncRequestResult.Requested).requestId
request(root, newRun, service.peerId(), desktopPeer)
archiveAcknowledgements(root, newRun, service.peerId(), desktopPeer)
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = claim,
)
}
assertTrue(old.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Superseded)
assertTrue(explicit.get(3, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
assertNotEquals("sr_old", freshRequestId)
assertEquals(
freshRequestId,
JSONObject(File(root, "trainlog-sync-request-v1.json").readText())
.getString("request_id"),
)
assertEquals(listOf(newRun), capturedRuns)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT run_id FROM sync_generations", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(newRun, cursor.getString(0))
assertEquals(false, cursor.moveToNext())
}
}
} finally {
executor.shutdownNow()
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun terminalBackgroundRunStaysSuppressedUntilNewRequestOrRemoteEvidence() {
val databaseName = "generation-suppression-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-suppression-").toFile()
val repository = TrainlogRepository(context, databaseName)
try {
val service = SyncGenerationService(repository)
val coordinator = SyncGenerationCoordinator(repository, arbiter = SyncConversationArbiter())
val desktopPeer = "peer_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
request(root, runId, service.peerId(), desktopPeer)
service.capture(root, desktopPeer, runId)
val terminal = BackgroundTerminalRun(runId, coordinator.remoteEvidence(root, runId))
repeat(5) {
assertEquals(
BackgroundRequestActionability.RESUMABLE_BUT_STALE,
coordinator.classifyBackgroundRequest(root, terminal),
)
}
File(root, "desktop-consumption-ack-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("run_id", runId)
.put("proof", "new")
.toString()
)
assertEquals(
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE,
coordinator.classifyBackgroundRequest(root, terminal),
)
request(root, "sy_${UUID.randomUUID()}", service.peerId(), desktopPeer)
assertEquals(
BackgroundRequestActionability.NEW_REQUEST,
coordinator.classifyBackgroundRequest(root, terminal),
)
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun cancelledExplicitConversationHandsSameGenerationToBackgroundWithoutDuplication() {
val databaseName = "generation-handoff-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-handoff-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val published = CountDownLatch(1)
val result = AtomicReference<ForegroundGenerationResult>()
val desktopPeer = "peer_${UUID.randomUUID()}"
val oldRunId = "sy_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
try {
val service = SyncGenerationService(repository)
request(root, oldRunId, service.peerId(), desktopPeer)
val coordinator =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { _, phase, _, _, _ ->
if (phase == "generation_reference_published") published.countDown()
},
)
val claim = arbiter.announceExplicit()
val thread = Thread {
result.set(
coordinator.run(
root,
Duration.ofSeconds(5),
afterPeerPublication = {
request(root, runId, service.peerId(), desktopPeer)
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = claim,
)
)
}
thread.start()
assertTrue(published.await(3, TimeUnit.SECONDS))
thread.interrupt()
thread.join(3_000)
assertTrue(result.get() is ForegroundGenerationResult.Cancelled)
assertEquals(
BackgroundRequestActionability.ACTIVE_HANDOFF,
coordinator.classifyBackgroundRequest(root, null),
)
val resumed = coordinator.run(root, Duration.ofMillis(250))
assertTrue(resumed is ForegroundGenerationResult.Failed)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE run_id=?", arrayOf(runId))
.use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
}

View file

@ -101,7 +101,15 @@ class SyncDeploymentConversationTest {
}
val conversations = if (suppliedTransport == null) 24 else 1
repeat(conversations) {
val coordinator = SyncGenerationForegroundCoordinator(repository)
val visibilityEvents = mutableListOf<List<String>>()
val tracePhases = mutableListOf<String>()
val coordinator = SyncGenerationForegroundCoordinator(
repository,
MtpPublicationVisibility { files ->
visibilityEvents += files.map(File::getName)
},
SyncGenerationTrace { _, phase, _, _, _ -> tracePhases += phase },
)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var requestSignals = 0
var refreshedAfterDesktopReference = false
@ -156,6 +164,34 @@ class SyncDeploymentConversationTest {
"transport is refreshed after the correlated desktop reference",
refreshedAfterDesktopReference,
)
val referenceVisibility = visibilityEvents.indexOfFirst {
it == listOf("android-generation-v1.json")
}
val objectVisibility = visibilityEvents.indexOfFirst { "manifest.json" in it }
assertTrue("generation objects must become visible", objectVisibility >= 0)
assertTrue(
"reference must follow complete object visibility",
referenceVisibility > objectVisibility,
)
assertTrue(
tracePhases.containsAll(
listOf(
"peer_published",
"full_generation_trigger_published",
"generation_request_observed",
"archive_ack_observed",
"generation_capture_started",
"generation_captured",
"generation_objects_published",
"generation_reference_published",
"desktop_ack_observed",
"desktop_generation_observed",
"desktop_generation_consumed",
"android_ack_published",
"completed",
)
)
)
var workerOutput = ""
localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText()

View file

@ -0,0 +1,56 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.nio.file.Files
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncGenerationConversationLockTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun foregroundConversationCannotBeStolenByBackgroundCoordinator() {
val databaseName = "generation-lock-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-lock-").toFile()
val repository = TrainlogRepository(context, databaseName)
val entered = CountDownLatch(1)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
try {
val foreground = SyncGenerationCoordinator(repository)
val background = SyncGenerationCoordinator(repository)
val active = executor.submit<ForegroundGenerationResult> {
foreground.run(
root,
Duration.ofMillis(300),
afterPeerPublication = { entered.countDown() },
)
}
assertTrue(entered.await(2, TimeUnit.SECONDS))
val duplicate = background.run(root, Duration.ofMillis(10))
assertEquals(ForegroundGenerationResult.Busy, duplicate)
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getInt(0))
}
}
} finally {
executor.shutdownNow()
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
}

View file

@ -357,9 +357,15 @@ class SyncGenerationServiceTest {
val repository = TrainlogRepository(context, databaseName)
try {
val coordinator = SyncGenerationCoordinator(repository)
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
File(root, "request-v1.json").writeText("not-json")
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
val peer = SyncGenerationService(repository).peerId()
val request =
@ -370,11 +376,17 @@ class SyncGenerationServiceTest {
.put("android_peer_id", "peer_${UUID.randomUUID()}")
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
File(root, "request-v1.json").writeText(request.toString())
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
request.put("android_peer_id", peer)
File(root, "request-v1.json").writeText(request.toString())
assertEquals(true, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NEW_REQUEST,
coordinator.classifyBackgroundRequest(root, null),
)
} finally {
repository.close()
context.deleteDatabase(databaseName)

View file

@ -0,0 +1,59 @@
package com.labfytools.trainlog.data
import java.nio.file.Files
import org.json.JSONObject
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncRequestOutboxTest {
@Test
fun distinctExplicitRequestsAlwaysPublishFreshIdentities() {
val directory = Files.createTempDirectory("trainlog-request-outbox-").toFile()
try {
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val first = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val firstBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
val second = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val secondBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
assertNotEquals(first.requestId, second.requestId)
assertNotEquals(firstBytes, secondBytes)
assertTrue(JSONObject(secondBytes).getString("request_id") == second.requestId)
} finally {
directory.deleteRecursively()
}
}
@Test
fun oneIntentUsesSameIdentityAcrossFullGenerationAndLegacyChannels() {
val directory = Files.createTempDirectory("trainlog-dual-request-").toFile()
try {
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val intent = outbox.newIntent()
assertTrue(outbox.publishFullGeneration(snapshot, intent) is SyncRequestResult.Requested)
assertTrue(outbox.publishLegacy(snapshot, intent) is SyncRequestResult.Requested)
val full = JSONObject(java.io.File(directory, FULL_GENERATION_REQUEST_NAME).readText())
val legacy = JSONObject(java.io.File(directory, LEGACY_SYNC_REQUEST_NAME).readText())
assertEquals(intent.requestId, full.getString("request_id"))
assertEquals(intent.requestId, legacy.getString("request_id"))
assertEquals(full.toString(), legacy.toString())
} finally {
directory.deleteRecursively()
}
}
}

View file

@ -139,8 +139,8 @@ class AiSessionDraftUiWiringTest {
} }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
.assertIsDisplayed()
compose.onNodeWithText("Tractions supination").assertIsDisplayed()
compose.onNodeWithText("3 × 10 · repos 90 s").assertIsDisplayed()
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
}

View file

@ -36,14 +36,10 @@ class AndroidUiRedesignTest {
HomeScreen(
activeDraft = null,
draftError = null,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
onSession = {},
onOpenExercises = {},
onOpenStatistics = {},
onBody = { bodyCalls++ },
onOpenLatestSession = {},
onSync = { syncCalls++ },
)
}

View file

@ -36,13 +36,14 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
assertEquals(7, AppSection.entries.size)
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(6, AppSection.entries.size)
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
assertEquals(AppSection.EXERCISES, AppRoute.LatestMaxima.section)
}
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {

View file

@ -0,0 +1,74 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BodyMetricGridTest {
@get:Rule val compose = createComposeRule()
private val items = listOf(
BodyMetricItem("POIDS", "kg", "", {}),
BodyMetricItem("COU", "cm", "", {}),
BodyMetricItem("TOUR DE TAILLE", "cm", "", {}),
BodyMetricItem("HANCHES", "cm", "", {}),
)
@Test
fun deployedPhoneContentWidthUsesTwoColumns() {
// 1080 px / 480 dpi = 360 dp; the screen frame leaves about 312 dp.
assertEquals(2, bodyMetricColumnCount(312f, 0.9f))
}
@Test
fun genuinelyNarrowWidthStacksFields() {
assertEquals(1, bodyMetricColumnCount(280f, 1f))
}
@Test
fun largeAccessibilityTextStacksFields() {
assertEquals(1, bodyMetricColumnCount(312f, 1.5f))
}
@Test
fun deployedPhoneWidthRendersPairedFieldsWithoutClippingUnits() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(312.dp)) { BodyMetricGrid(items) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.left > weight.left)
assertTrue(kotlin.math.abs(neck.top - weight.top) < 2f)
compose.onNodeWithText("kg").fetchSemanticsNode()
assertTrue(compose.onAllNodesWithText("cm", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty())
compose.onNodeWithText("TOUR DE TAILLE").fetchSemanticsNode()
}
@Test
fun trulyNarrowLayoutStacksPairedFields() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(280.dp)) { BodyMetricGrid(items.take(2)) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.top > weight.top)
}
}

View file

@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
var openedZone: String? = null
var statisticsOpened = false
setSection(
widthDp = 380,
fontScale = 1f,
onOpenExercises = { openedZone = it },
onOpenStatistics = { statisticsOpened = true },
)
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertEquals("core", openedZone)
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertTrue(statisticsOpened)
}
private fun setSection(
widthDp: Int,
fontScale: Float,
onOpenExercises: (String) -> Unit = {},
onOpenStatistics: () -> Unit = {},
) {
val zones = allZoneIds.map { id ->
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
BodyZoneHomeSection(
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
onOpenExercises,
onOpenStatistics,
)
}
}

View file

@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(2, directory.enumerationCount)
val required = listOf(
"trainlog-mobile-export-v3.json",
@ -138,7 +138,7 @@ class SyncBundlePublicationTest {
ShadowLog.clear()
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })

View file

@ -57,6 +57,30 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
The desktop remains the canonical long-term history and analytics store.
## Capture-first interface
The Android interface uses one compact dark design system for cards, action
tiles, buttons, selection states, and destructive actions. Controls share a
restrained corner radius instead of screen-specific pill shapes. The Home
screen is limited to starting or resuming capture, recent BODY ZONES exposure,
synchronization, and body measurements. Analytics and Statistics are not
mobile navigation destinations; the desktop remains their owner. The local
statistics-compatible data and exchange contracts are unchanged.
Sessions exposes manual capture, prepared drafts, completed history, and
Programs as structured actions. Program cards show status, dates, session
count, and completed progress. Program detail shows each planned session as
`todo`, `in progress`, or `completed`, with type, date, exercise count, and a
start/resume action. Starting continues to use the schema-v25 durable singleton
and stable Program provenance described below.
The active editor uses side-by-side Training/MAX selection and compact set
cards. Repetitions and load share one row when space permits; the per-set
delete action stays inside the card and retains its confirmation contract.
Exercise filters use wrapping selection tiles, while catalog rows, drafts,
exercise actions, synchronization actions, Settings actions, and latest MAX
entries use the same card/button vocabulary.
## Interface language (`TRAINLOG_I18N_V0_1_1`)
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product
@ -722,6 +746,7 @@ Android writes:
```text
trainlog-sync-request-v1.json
trainlog-sync-full-generation-request-v1.json
```
The request is replaced through the same direct `Documents/Trainlog` directory
@ -1032,10 +1057,52 @@ coordinator waits for correlated objects, tolerates stale objects left by
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements.
Each explicit foreground generation attempt publishes the Android peer before
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
Each explicit foreground synchronization attempt publishes the Android peer
and enters the full-generation coordinator before emitting one fresh request
signal for `trainlog-syncd`. The signal is only admission/correlation; a
configured daemon routes it to the existing desktop orchestrator rather than
the standalone V3 importer. If transport is
interrupted after generation publication, a later attempt resumes the exact
stored generation instead of recapturing mutable data. An acknowledged Android
generation remains resumable only until the desktop generation for that run is
durably consumed; a fully completed run is then excluded from foreground
selection. This preserves late-ACK recovery without replaying completed runs.
The direct-MTP path confirms transport visibility independently from ordinary
`File.exists()` checks. Android fsyncs and atomically commits every immutable
generation artifact, asks MediaProvider to scan those files, waits for all
bounded callbacks, and only then atomically publishes and scans
`android-generation-v1.json`. The same boundary covers the peer advertisement,
explicit request signal, Android consumption ACK and bounded error reference.
Manifest/reference remain the commit markers; scanning does not change their
bytes or protocol meaning. Production logging records only `run_id`, phase,
artifact name, generation ID and result.
When background synchronization is enabled, an explicit UI request announces
foreground ownership before refreshing the foreground listener. Service
liveness and conversation ownership are separate: the service may keep the
process alive, but it cannot acquire automatic ownership while explicit intent
is pending. The process-wide arbiter gives `NEW_EXPLICIT` priority over
`RESUME_BACKGROUND`; a background waiter observes cooperative yield, returns a
typed superseded result, and releases ownership before the foreground emits a
fresh request UUID. The explicit coordinator baselines the existing desktop
run and accepts only a different run after that trigger, so it cannot attach
the click to a stale `request-v1.json`.
An interrupted explicit conversation leaves captured/published generations
durable. The listener may resume that same run without recapture or a second
generation. Automatic actionability separately classifies a new request, new
correlated desktop ACK/reference evidence, a legitimate active handoff, and a
stale resumable generation. A locally terminal run is suppressed until its
run ID changes or its correlated remote evidence advances; mere presence of a
resumable generation does not schedule the same run every five seconds.
The Mensurations grid uses two 140 dp compact fields plus a 10 dp gutter at
normal phone widths. The deployed 1080 px/480 dpi phone supplies approximately
312 dp inside the screen frame and therefore receives two columns. Layouts
below 290 dp, or font scales of 1.5 and above, retain the readable one-column
fallback.
Backup manifests retain format version 1 and the current Android schema. Their
`product_version` is read from installed package metadata, so development
builds record `0.1.3` without a second hard-coded product-version source.

View file

@ -456,10 +456,11 @@ manifest is published after its member objects. The helper runs inside the
orchestrator-owned process group and cannot be selected or parameterized by an
HTTP request.
It polls for a new Android request and invokes the same shared C synchronization
engine used by the TUI.
It does not implement a second synchronization algorithm.
It polls for a new Android request. With trusted full-generation configuration
it admits that request to the existing application orchestrator and peer
worker; without that configuration it retains the shared C/V3 compatibility
engine used by the TUI. It does not implement a second synchronization
algorithm.
## 3. Exercise model

View file

@ -7,7 +7,38 @@ basis without rolling back USB. Both transports move the same generation/ACK
bytes; neither transports SQLite. Android has an explicitly enabled foreground
USB listener with mandatory notification and a private-folder Drive grant with
bounded WorkManager checks. Both use the same concurrency-guarded generation
coordinator as SyncScreen.
coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background
resumption, while the foreground service may remain alive without owning the
conversation. An automatic run yields cooperatively at safe boundaries; its
durable generation is retained for a correlated handoff rather than deleted.
Background actionability distinguishes a new request or new correlated remote
evidence from a merely stale resumable generation, preventing five-second
terminal retry loops.
Direct-MTP publication now has an explicit Android visibility boundary.
Durable immutable generation files are submitted to MediaProvider and all scan
callbacks complete before the mutable generation reference is published; the
reference and Android ACK use the same confirmation. Bounded diagnostic logs
contain only run/generation identities, artifact names, phases and results.
The desktop worker performs lightweight local correlation checks continuously
but opens a full MTP pull no more than once every three seconds. A 30-second
individual adapter budget is distinct from the overall conversation deadline,
so an absent correlated generation reports a protocol wait timeout while an
actually blocked adapter reports `transport_timeout`.
The resumed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
desktop request and archive-ACK files, captured generation
`gen_532da95e-713f-4163-acc1-d838673c33bf`, and published its immutable objects
and reference with manifest SHA-256
`170b55ad5bf3e44501fb506107cd26ab807d0179c95a5f1bf73f8251ea1a14e8`.
Filesystem and MTP exposed the same bytes. It then waited for a desktop ACK
that could not arrive because the daemon correctly refused to create another
conversation for the already-consumed request UUID. No causal or business
mutation occurred. The remaining defect was Android ownership: an automatic
resume could hold the old run while the explicit foreground attempt failed
before publishing a fresh request UUID. The arbiter and explicit run baseline
now prevent that state.
Existing preparation rows expose separate editing and Android-delivery states
and an accessible send action. Draft/local advances the same preparation to a
@ -16,7 +47,11 @@ Completed history hides the active preparation exclusively through the
delivery's stable execution-session identity and retains all provenance.
The opt-in full-generation path is operational on the private daily
installation. The loopback Web control and foreground Android coordinator use
installation. Android explicit synchronization uses a dedicated coordination
filename carrying the unchanged request-v1 payload. Legacy V3 signalling is
additive and occurs only after a fresh strict V3 snapshot succeeds; daemon
request identities are deduplicated across both bounded channels. The loopback
Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
persistent peer identities. Controlled USB and private Drive validation proved
the complete Web-initiated request, generation, import, publication, and ACK
@ -152,15 +187,20 @@ Android inbox/outbox still select V3 and do not move the draft artifact.
boundaries, not active storage choices.
- Desktop synchronization uses direct libudev/libmtp discovery and object
access. No GVFS/FUSE mount is required.
- `trainlog_sync_run()` is shared by the TUI and `trainlog-syncd`.
- `trainlog_sync_run()` remains the strict legacy V3 engine shared with the
TUI and compatibility tooling. When the trusted full-generation
configuration is present, `trainlog-syncd` admits the Android request into
the existing `sync_orchestrator.py`/`sync_peer_worker.py` conversation;
configuration absence retains the legacy fallback.
- The desktop imports Android snapshots, publishes catalog/profile/feedback/AI
companions, and returns a receipt. Stable IDs, artifact idempotence and the
few domain-specific explicit states make defined replay paths idempotent.
Explicit staged generation services now provide coherent V4/companion
capture, immutable manifests, whole-generation SQLite consumption and
durable peer ACKs. A trusted opt-in configuration may select the bounded
durable peer ACKs. The Android foreground trigger now publishes a complete
generation by default. A trusted desktop configuration selects the bounded
production generation MTP adapter; absent configuration leaves V3 as the
default.
compatibility fallback.
- SQLite database files are never synchronized.
- The desktop AI flow uses external `rclone` for Drive inbox/archive and
read-only history export. Android owns no Drive credentials.
@ -323,6 +363,25 @@ against desktop schema v27 without transferring ownership of Program
definitions. Web and Android derive their session state from these persisted
identities; no display-field heuristic is used.
Android is capture-first: its drawer no longer exposes Statistics, and Home is
limited to capture, recent BODY ZONES exposure, synchronization, and body
measurements. A shared compact card/button system covers Sessions, Programs,
drafts, exercise catalog/detail/editing, synchronization, Settings, and MAX
presentation. Program list/detail surfaces show completed counts and explicit
todo/in-progress/completed states; the active editor uses exclusive side-by-
side session-type controls and compact two-column set cards. These are
presentation changes only and do not alter schemas or exchange artifacts.
Web Program administration retains revision-guarded deletion and now presents
adaptive cards plus an explicit completed-session progress indicator.
The full Android lint comparison against an isolated clean `main` worktree is
unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The next private daily Android candidate remains on development
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied
as a build-time override. No public tag or release is created by that private
candidate process.
The private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without
uninstalling or clearing application data. A strictly disposable Program was

View file

@ -135,9 +135,11 @@ Synchronization artifacts are separate from the frozen Trainlog session JSON
v1 format.
V4 history and execution-draft V1 are staged codecs with explicit repository
and command-line entry points. They are deliberately absent from
`trainlog_sync_run()`, request/receipt publication and the Android automatic
inbox/outbox. Deployment must stop the old user worker, replace the installed
and command-line entry points. They remain deliberately absent from
`trainlog_sync_run()`: configured Android/daemon requests now route around that
strict legacy V3 engine into the existing full-generation orchestrator. The
request artifact remains only a correlated admission signal. Deployment must
stop the old user worker, replace the installed
binary/scripts as one unit, migrate only through the new binary, and restart
only after compatibility checks; this ticket performs none of those steps.
@ -183,6 +185,7 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
No SQLite file is transferred.
@ -915,7 +918,8 @@ for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state.
The peer publication precedes one fresh `trainlog-sync-request-v1` signal on
The peer publication precedes one fresh
`trainlog-sync-full-generation-request-v1` signal on
every explicit Android attempt. This wakes the existing user-session daemon;
it is not an ACK and does not alter generation identity. A retry may resume an
exact `captured`, `published`, or `waiting_acknowledgement` generation. It may
@ -957,6 +961,19 @@ It reads the peer advertisement, current Android generation reference and
current Android consumption ACK, then downloads only the generation directory
named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history.
Local correlation is checked every 50 ms without USB work; complete MTP pulls
are throttled to a three-second cadence. Each MTP operation has its own
30-second ceiling while the conversation retains its configured global
deadline. Exhausting the global deadline while waiting for a correlated
reference reports `timeout waiting for correlated android-generation-v1.json`;
only an adapter that exceeds its independent operation budget reports
`transport_timeout`.
Android direct-file publication has a separate MediaProvider visibility
barrier. Immutable generation files, including the manifest, become visible
before `android-generation-v1.json` is committed. The reference therefore
cannot advertise a generation whose files have not completed the Android MTP
visibility callbacks. No remote object is removed during polling or scanning.
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
change the frozen Dashboard `next_session.available` field or any exchange

View file

@ -13,18 +13,20 @@ record is [sync rollout V1 deployment](reviews/sync_rollout_v1_deployment.md).
## Deployment-readiness contract
The generation path remains disabled unless desktop configuration authorizes a
specific persisted Android peer and Android contains the strict opt-in document
`Documents/Trainlog/trainlog-sync-generation-opt-in-v1.json`. Absence preserves
V3; malformed opt-in fails explicitly. The Web coordinator owns a fixed worker,
bounded typed progress and its descendant process group. The foreground Android
coordinator advertises its real peer identity, publishes manifest-last bytes,
consumes the return generation and emits a durable correlated ACK.
The desktop generation path remains disabled unless trusted local configuration
authorizes a specific persisted Android peer. Android foreground synchronization
uses its generation coordinator by default; the request artifact is a correlated
admission signal, not a switch back to standalone V3. Configuration absence on
desktop preserves the strict V3 compatibility fallback. The Web coordinator owns
a fixed worker, bounded typed progress and its descendant process group. The
foreground Android coordinator advertises its real peer identity, publishes
manifest-last bytes, consumes the return generation and emits a durable
correlated ACK.
The isolated proof still uses the production generation adapter with only its
typed libudev/libmtp I/O callbacks replaced by an object-store double. The
separate deployment record owns the physical-phone evidence; neither proof
generalizes pairing to another phone or enables generation mode by default.
generalizes pairing to another phone or authorizes an unconfigured desktop.
### Candidate, backup and recovery

View file

@ -656,9 +656,18 @@ tagged source state, and the tag, Android `versionName`, Meson project version,
TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
integer while keeping `versionName=0.1.2`. This build-only override is used only
to maintain a strictly increasing installed → backup bridge → current APK
chain; it does not change any schema or exchange-format version.
integer above the installed package while retaining the current development
`versionName=0.1.3`. The version-code override is build-only and does not change
any schema or exchange-format version. Private daily candidates must not
downgrade the logical product version to the earlier 0.1.2 stable line.
For `TRAINLOG_UI_ANDROID_WEB_UNIFICATION_V1`, `lintDebug` was also run from a
detached clean worktree at the exact `main` baseline and from the mission
branch. Both runs report the same ten errors: one `NewApi` diagnostic for
`windowLightNavigationBar`, one `LocalContextConfigurationRead`, and eight
`StringFormatMatches` diagnostics (four bilingual call sites). Comparison by
rule and complete diagnostic message reports zero added and zero removed
errors. No lint rule or severity was changed.
```bash
meson setup --reconfigure build
@ -1503,6 +1512,18 @@ the `trainlog-syncd` launcher and daemon source, and the generation worker.
This prevents a user unit from silently executing a helper from a different
checkout than the installed desktop candidate.
`syncd_routing` proves that both Android-labelled and daemon-labelled
admissions invoke the packaged `sync_orchestrator.py` full-generation owner
when trusted configuration is present, and never invoke the legacy
`trainlog-sync-once` command in that mode. The generation-MTP boundary test
proves that polling copies the Android request signal without acknowledging or
deleting it. Session-exchange characterization keeps standalone V3 rejection
strict while its complete-envelope case filters a tombstone-dominated live
fact; the causal Android/Desktop round-trip and generation replay/ACK tests
cover restart, idempotence and non-resurrection. `AndroidBackupServiceTest`
injects a product version and verifies that exact value in the ZIP manifest,
preventing a return to a hard-coded release string.
The isolated JDK 17 harness reports **220 Android tests: 215 passed, five
skipped, zero failures/errors**. The skips are optional, externally supplied
copy gates: `BridgeBackupMigrationTest.verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner`,
@ -1523,6 +1544,41 @@ fixture's sample draft.
## Private full-generation rollout validation
Routing regressions cover a strict V3 refusal in the presence of causal
deletions, publication of the dedicated full-generation request before legacy
compatibility, same-identity dual-channel deduplication, full-generation
priority over stale legacy requests, and bounded durable seen-request state.
The MTP visibility regression suite uses injected clocks and transport
callbacks, with no multi-second sleeps. It covers delayed Android publication,
multiple throttled polls, stale run references, a never-visible generation,
an independently blocked adapter, and exact protocol-versus-transport timeout
classification. Android tests prove fresh request UUIDs, foreground/background
lock ownership, objects-visible-before-reference ordering, bounded phase
tracing, and a completed full-generation conversation. Compose tests exercise
the measured 312 dp phone content width as two columns, a true 280 dp narrow
layout as one column, 1.5 font-scale fallback, long French labels and `kg`/`cm`
suffixes.
Android conversation-ownership regressions reproduce the production race: an
automatic owner waiting on an old resumable run yields to explicit foreground
intent, the click publishes a fresh request UUID, the explicit coordinator
ignores the pre-click desktop run, and exactly one generation is captured for
the new run. Additional fixtures cover typed cancellation and durable handoff,
terminal-run suppression across five scheduling cycles, reactivation by new
correlated desktop evidence, and exclusive ownership. Desktop routing tests
retain the complementary invariant that an already-consumed Android request
UUID cannot create a second daemon run.
The resumed run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` subsequently proved
the MTP publication path: Android captured
`gen_532da95e-713f-4163-acc1-d838673c33bf`, published the immutable objects and
reference, and exposed bytes identical to the filesystem through MTP. It
stopped after `generation_reference_published` because no ACK could be created
for the daemon-deduplicated old request UUID. This evidence supersedes the
earlier pre-resume forensic snapshot without rewriting or deleting the
incomplete generation.
The authorized 2026-09-17 rollout added regression coverage for production
failures found only after installation: feedback import under `sqlite3.Row`,
the complete exporter inventory of the relocatable package, stale Android ACK

View file

@ -75,6 +75,10 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file())
self.assertTrue((output / "libexec/trainlog-sync-once").is_file())
self.assertIn(
'TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"',
(output / "bin/trainlog-syncd").read_text(),
)
stable_bin = Path(directory) / "stable-bin"
stable_bin.mkdir()
(stable_bin / "trainlog").symlink_to(output / "bin/trainlog")

View file

@ -1,5 +1,6 @@
import importlib.util
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
@ -30,6 +31,108 @@ class SyncPeerWorkerTest(unittest.TestCase):
self.assertGreaterEqual(timeout, 178)
self.assertLessEqual(timeout, 180)
def test_mtp_adapter_uses_fixed_operation_budget(self):
completed = subprocess.CompletedProcess([], 0, b"", b"")
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
Path("/private/transport"), 100.0, clock=lambda: 50.0,
)
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
def test_mtp_pull_pump_is_throttled(self):
now = [0.0]
pulls = []
pump = worker.MtpPullPump(
lambda: pulls.append(now[0]), interval=3.0, clock=lambda: now[0]
)
for timestamp in (0.05, 0.5, 2.99, 3.0, 3.1, 5.99, 6.0):
now[0] = timestamp
pump()
self.assertEqual([3.0, 6.0], pulls)
def test_global_deadline_is_not_reported_as_transport_timeout(self):
with self.assertRaises(worker.ConversationDeadlineExpired):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
Path("/private/transport"), 10.0, clock=lambda: 10.0,
)
def test_deadline_limited_pull_becomes_protocol_timeout(self):
with mock.patch.object(
worker.subprocess, "run", side_effect=subprocess.TimeoutExpired(["adapter"], 0.5)
):
with self.assertRaises(worker.ConversationDeadlineExpired):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
Path("/private/transport"), 10.5, clock=lambda: 10.0,
)
def test_blocked_full_budget_pull_is_transport_timeout(self):
with mock.patch.object(
worker.subprocess, "run", side_effect=subprocess.TimeoutExpired(["adapter"], 30)
):
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
Path("/private/transport"), 100.0, clock=lambda: 50.0,
)
def test_delayed_correlated_generation_survives_multiple_polls(self):
with tempfile.TemporaryDirectory() as directory:
reference = Path(directory) / "android-generation-v1.json"
now = [0.0]
pulls = []
def pump():
pulls.append(now[0])
if len(pulls) == 3:
reference.write_text('{"run_id":"sy_fresh"}', encoding="utf-8")
value = worker.wait_json(
reference, 10.0,
lambda candidate: candidate.get("run_id") == "sy_fresh",
pump, clock=lambda: now[0],
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
)
self.assertEqual("sy_fresh", value["run_id"])
self.assertEqual(3, len(pulls))
def test_stale_reference_is_ignored_until_correlated_replacement(self):
with tempfile.TemporaryDirectory() as directory:
reference = Path(directory) / "android-generation-v1.json"
reference.write_text('{"run_id":"sy_stale"}', encoding="utf-8")
now = [0.0]
polls = [0]
def pump():
polls[0] += 1
if polls[0] == 2:
reference.write_text('{"run_id":"sy_fresh"}', encoding="utf-8")
value = worker.wait_json(
reference, 1.0,
lambda candidate: candidate.get("run_id") == "sy_fresh",
pump, clock=lambda: now[0],
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
)
self.assertEqual("sy_fresh", value["run_id"])
def test_missing_generation_reports_correlated_protocol_timeout(self):
with tempfile.TemporaryDirectory() as directory:
reference = Path(directory) / "android-generation-v1.json"
reference.write_text('{"run_id":"sy_stale"}', encoding="utf-8")
now = [0.0]
with self.assertRaisesRegex(
RuntimeError, "timeout waiting for correlated android-generation-v1.json"
):
worker.wait_json(
reference, 0.2,
lambda candidate: candidate.get("run_id") == "sy_fresh",
clock=lambda: now[0],
sleeper=lambda duration: now.__setitem__(0, now[0] + duration),
)
if __name__ == "__main__":
unittest.main()

191
tests/test_syncd_routing.py Normal file
View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Regression coverage for Android/daemon full-generation admission."""
import argparse
import importlib.util
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("trainlog_syncd", ROOT / "tools/trainlog_syncd.py")
syncd = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(syncd)
class SyncdRoutingTest(unittest.TestCase):
def test_android_and_daemon_triggers_route_to_existing_orchestrator(self):
for trigger in ("android", "daemon"):
with self.subTest(trigger=trigger), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
transport = root / "transport"
transport.mkdir()
config = root / "config.json"
config.write_text(
json.dumps(
{
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": "mtp",
"expected_peer_id": "peer_11111111-1111-4111-8111-111111111111",
"transport_root": str(transport),
"owned_root": str(root / "owned"),
"timeout_seconds": 30,
}
)
)
args = argparse.Namespace(
mtp_adapter=root / "adapter",
state=root / "trainlog.db.sync-run.json",
trigger=trigger,
orchestrator=root / "sync_orchestrator.py",
database=root / "trainlog.db",
)
commands = []
def execute(command, **_kwargs):
commands.append(command)
if command[0] == str(args.mtp_adapter):
(transport / syncd.REQUEST_NAME).write_text(
json.dumps(
{
"format": "trainlog-sync-request",
"version": 1,
"request_id": "sr_22222222-2222-4222-8222-222222222222",
"requested_at": "2026-09-20T12:00:00Z",
}
)
)
return subprocess.CompletedProcess(command, 0, "PASS\n", "")
with mock.patch.object(syncd.subprocess, "run", side_effect=execute), mock.patch.object(
syncd, "append_log"
):
self.assertEqual(0, syncd.run_full_generation(args, config))
self.assertEqual(str(args.mtp_adapter), commands[0][0])
self.assertEqual(str(args.orchestrator), commands[1][1])
self.assertNotIn("trainlog-sync-once", " ".join(commands[1]))
state = json.loads(args.state.read_text())
self.assertEqual(trigger, state["trigger"])
self.assertEqual("full_generation_v1", state["effective_mode"])
def test_consumed_request_id_does_not_create_a_second_desktop_run(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
transport = root / "transport"
transport.mkdir()
request = "sr_22222222-2222-4222-8222-222222222222"
config = root / "config.json"
config.write_text(
json.dumps(
{
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": "mtp",
"expected_peer_id": "peer_11111111-1111-4111-8111-111111111111",
"transport_root": str(transport),
"owned_root": str(root / "owned"),
"timeout_seconds": 30,
}
)
)
state = root / "trainlog.db.sync-run.json"
state.write_text(json.dumps({"request_id": request, "run_id": "sy_old"}))
args = argparse.Namespace(
mtp_adapter=root / "adapter",
state=state,
trigger="android",
orchestrator=root / "sync_orchestrator.py",
database=root / "trainlog.db",
)
commands = []
def execute(command, **_kwargs):
commands.append(command)
(transport / syncd.REQUEST_NAME).write_text(
json.dumps(
{
"format": "trainlog-sync-request",
"version": 1,
"request_id": request,
"requested_at": "2026-09-20T12:00:00Z",
}
)
)
return subprocess.CompletedProcess(command, 0, "PASS\n", "")
with mock.patch.object(syncd.subprocess, "run", side_effect=execute):
self.assertEqual(3, syncd.run_full_generation(args, config))
self.assertEqual(str(args.mtp_adapter), commands[0][0])
self.assertEqual(1, len(commands))
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
def test_android_production_publishes_full_generation_before_optional_legacy(self):
source = (ROOT / "android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt").read_text()
production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(
"internal suspend fun publishLegacyBundleAndRequest", 1
)[0]
self.assertIn("coordinator.run", production)
self.assertNotIn("generation-opt-in", production)
self.assertIn("exportMobileBundle", production)
self.assertLess(
production.index("publishFullGeneration"), production.index("exportMobileBundle")
)
self.assertGreater(
production.index("publishLegacy"), production.index("exportMobileBundle")
)
def test_full_generation_wins_and_retires_older_legacy(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
old = "sr_11111111-1111-4111-8111-111111111111"
new = "sr_22222222-2222-4222-8222-222222222222"
self._write_request(root / syncd.REQUEST_NAME, old, "2026-09-20T11:00:00Z")
self._write_request(
root / syncd.FULL_GENERATION_REQUEST_NAME, new, "2026-09-20T12:00:00Z"
)
selected = syncd.select_request(root, [])
self.assertEqual((new, "full_generation", [old]), selected)
self.assertIsNone(syncd.select_request(root, [old, new]))
def test_dual_channel_same_intent_is_one_request(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
request = "sr_22222222-2222-4222-8222-222222222222"
for name in (syncd.REQUEST_NAME, syncd.FULL_GENERATION_REQUEST_NAME):
self._write_request(root / name, request, "2026-09-20T12:00:00Z")
self.assertEqual(
(request, "full_generation", []), syncd.select_request(root, [])
)
self.assertIsNone(syncd.select_request(root, [request]))
def test_newer_legacy_remains_eligible_after_full_generation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
full = "sr_22222222-2222-4222-8222-222222222222"
legacy = "sr_33333333-3333-4333-8333-333333333333"
self._write_request(
root / syncd.FULL_GENERATION_REQUEST_NAME, full, "2026-09-20T12:00:00Z"
)
self._write_request(root / syncd.REQUEST_NAME, legacy, "2026-09-20T13:00:00Z")
self.assertEqual((full, "full_generation", []), syncd.select_request(root, []))
self.assertEqual((legacy, "legacy", [full]), syncd.select_request(root, [full]))
@staticmethod
def _write_request(path, request_id, requested_at):
path.write_text(json.dumps({
"format": "trainlog-sync-request",
"version": 1,
"request_id": request_id,
"requested_at": requested_at,
}))
if __name__ == "__main__":
unittest.main()

View file

@ -9,6 +9,7 @@ ROOT="$(
)"
SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once"
MTP_ADAPTER="$ROOT/build/tui/trainlog-generation-mtp-adapter"
DAEMON="$ROOT/tools/trainlog_syncd.py"
if [[ ! -x "$SYNC_ONCE" ]]; then
@ -17,6 +18,12 @@ if [[ ! -x "$SYNC_ONCE" ]]; then
exit 1
fi
if [[ ! -x "$MTP_ADAPTER" ]]; then
echo "missing executable: $MTP_ADAPTER" >&2
echo "run meson compile -C build first" >&2
exit 1
fi
mkdir -p \
"$HOME/.local/bin" \
"$HOME/.config/systemd/user"
@ -37,7 +44,7 @@ Description=Trainlog Android MTP synchronization agent
[Service]
Type=simple
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --interval 3
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --mtp-adapter "$MTP_ADAPTER" --interval 3
Restart=on-failure
RestartSec=2

View file

@ -94,7 +94,7 @@ def main() -> int:
)
syncd_launcher = args.output / "bin/trainlog-syncd"
syncd_launcher.write_text(
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
)
files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = {

View file

@ -34,6 +34,30 @@ CAPS = {
"session-preparations-v2",
}
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
MTP_POLL_INTERVAL_SECONDS = 3.0
class ConversationDeadlineExpired(RuntimeError):
"""Internal signal that the protocol deadline, not the transport, expired."""
class MtpPullPump:
"""Throttle complete MTP refreshes while local correlation is polled."""
def __init__(self, pull, interval=MTP_POLL_INTERVAL_SECONDS, clock=time.monotonic):
self._pull = pull
self._interval = interval
self._clock = clock
self._last_pull = clock()
def __call__(self, force: bool = False) -> None:
now = self._clock()
if not force and now - self._last_pull < self._interval:
return
self._pull()
self._last_pull = self._clock()
def emit(run_id: str, phase: str, **values: object) -> None:
value = {
@ -97,9 +121,13 @@ def run_adapter(
root: Path,
deadline: float,
allow_missing_peer: bool = False,
clock=time.monotonic,
) -> bool:
remaining = max(1, int(deadline - time.monotonic()))
timeout = min(remaining, 30)
remaining = deadline - clock()
if remaining <= 0:
raise ConversationDeadlineExpired()
timeout = min(remaining, MTP_OPERATION_TIMEOUT_SECONDS)
deadline_limited = remaining < MTP_OPERATION_TIMEOUT_SECONDS
try:
result = subprocess.run(
[str(adapter), operation, peer, str(root)],
@ -109,10 +137,13 @@ def run_adapter(
check=False,
)
except subprocess.TimeoutExpired as error:
if deadline_limited:
raise ConversationDeadlineExpired() from error
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
# never implies rollback, replay, or permission to retry a mutation.
raise RuntimeError(
f"transport_timeout: MTP {operation} did not finish within {timeout} seconds"
f"transport_timeout: MTP {operation} did not finish within "
f"{MTP_OPERATION_TIMEOUT_SECONDS:g} seconds"
) from error
if result.returncode != 0:
diagnostic = (
@ -248,29 +279,44 @@ def push_drive(
run_drive_adapter(adapter, "push", remote, outbox, deadline)
def wait_file(path: Path, deadline: float, pump=None) -> None:
def wait_file(path: Path, deadline: float, pump=None, clock=time.monotonic, sleeper=time.sleep) -> None:
while not path.is_file():
if time.monotonic() >= deadline:
if clock() >= deadline:
raise RuntimeError(f"timeout waiting for {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
try:
pump()
except ConversationDeadlineExpired as error:
raise RuntimeError(f"timeout waiting for {path.name}") from error
sleeper(0.05)
def wait_json(path: Path, deadline: float, predicate, pump=None) -> dict:
def wait_json(
path: Path,
deadline: float,
predicate,
pump=None,
clock=time.monotonic,
sleeper=time.sleep,
) -> dict:
while True:
wait_file(path, deadline, pump)
wait_file(path, deadline, pump, clock, sleeper)
try:
value = json.loads(bounded(path))
except (OSError, json.JSONDecodeError):
value = None
if isinstance(value, dict) and predicate(value):
return value
if time.monotonic() >= deadline:
if clock() >= deadline:
raise RuntimeError(f"timeout waiting for correlated {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
try:
pump()
except ConversationDeadlineExpired as error:
raise RuntimeError(
f"timeout waiting for correlated {path.name}"
) from error
sleeper(0.05)
def refresh_mtp_peer(
@ -291,19 +337,24 @@ def refresh_mtp_peer(
"""
peer_path = root / "android-peer-v1.json"
while True:
pulled = run_adapter(
adapter,
"pull",
peer,
root,
deadline,
True,
)
try:
pulled = run_adapter(
adapter,
"pull",
peer,
root,
deadline,
True,
)
except ConversationDeadlineExpired as error:
raise RuntimeError("timeout waiting for android-peer-v1.json") from error
if pulled and peer_path.is_file():
return
if time.monotonic() >= deadline:
raise RuntimeError("timeout waiting for android-peer-v1.json")
time.sleep(0.05)
# A complete libmtp discovery is the expensive poll. Keep retries
# interactive without continuously reopening the USB/MTP session.
time.sleep(min(MTP_POLL_INTERVAL_SECONDS, max(0.0, deadline - time.monotonic())))
def correlated_peer_error(root: Path, run_id: str) -> None:
@ -360,20 +411,22 @@ def main() -> int:
if args.mode == "mtp":
if args.mtp_adapter is None:
raise RuntimeError("MTP adapter is required")
pump = lambda: run_adapter(
args.mtp_adapter,
"pull",
args.expected_peer,
args.transport_root,
deadline,
True,
)
refresh_mtp_peer(
args.mtp_adapter,
args.expected_peer,
args.transport_root,
deadline,
)
pump = MtpPullPump(
lambda: run_adapter(
args.mtp_adapter,
"pull",
args.expected_peer,
args.transport_root,
deadline,
True,
)
)
elif args.mode == "drive":
if args.drive_adapter is None or not args.drive_remote:
raise RuntimeError("Drive adapter and remote are required")

View file

@ -4,13 +4,24 @@
from __future__ import annotations
import argparse
import json
import os
import re
import signal
import subprocess
import sys
import tempfile
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
STOP = False
REQUEST_NAME = "trainlog-sync-request-v1.json"
FULL_GENERATION_REQUEST_NAME = "trainlog-sync-full-generation-request-v1.json"
SEEN_REQUEST_LIMIT = 64
REQUEST_ID = re.compile(r"sr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
def request_stop(
@ -46,6 +57,176 @@ def append_log(
handle.write("\n")
def default_database() -> Path:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
return data_home / "trainlog/trainlog.db"
def atomic_json(path: Path, value: dict) -> None:
raw = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
descriptor, temporary = tempfile.mkstemp(prefix=".sync-run-", dir=path.parent)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def load_generation_config(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if (
not isinstance(value, dict)
or value.get("format") != "trainlog-sync-orchestrator-config"
or value.get("enabled") is not True
or value.get("mode") not in ("mtp", "auto")
):
raise RuntimeError("trusted full-generation MTP configuration is invalid")
return value
def request(path: Path) -> tuple[str, datetime] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict):
return None
candidate = value.get("request_id")
if (
set(value) == {"format", "version", "request_id", "requested_at"}
and value.get("format") == "trainlog-sync-request"
and type(value.get("version")) is int
and value.get("version") == 1
and isinstance(candidate, str)
and REQUEST_ID.fullmatch(candidate)
):
requested_at = value.get("requested_at")
if not isinstance(requested_at, str):
return None
try:
timestamp = datetime.fromisoformat(requested_at.replace("Z", "+00:00"))
except ValueError:
return None
if timestamp.tzinfo is None:
return None
return candidate, timestamp
return None
def load_request_state(path: Path) -> tuple[dict, list[str]]:
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return {}, []
if not isinstance(state, dict):
return {}, []
seen = [item for item in state.get("seen_request_ids", [])
if isinstance(item, str) and REQUEST_ID.fullmatch(item)]
historical = state.get("request_id")
if isinstance(historical, str) and REQUEST_ID.fullmatch(historical) and historical not in seen:
seen.append(historical)
return state, seen[-SEEN_REQUEST_LIMIT:]
def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] | None:
"""Select one unseen intent while preventing an older cross-channel replay.
WHY: modern Android may publish the same intent on full-generation and
legacy channels, while stale legacy files remain durable on MTP. CONTRACT:
full-generation wins and an older alternate intent is retired. INVARIANT:
one request_id starts at most one daemon conversation.
"""
full = request(root / FULL_GENERATION_REQUEST_NAME)
legacy = request(root / REQUEST_NAME)
unseen_full = full if full is not None and full[0] not in seen else None
unseen_legacy = legacy if legacy is not None and legacy[0] not in seen else None
if unseen_full is not None:
selected_id, selected_at = unseen_full
retired = list(seen)
if (
unseen_legacy is not None
and unseen_legacy[0] != selected_id
and unseen_legacy[1] <= selected_at
):
retired.append(unseen_legacy[0])
return selected_id, "full_generation", retired
if unseen_legacy is not None:
return unseen_legacy[0], "legacy", list(seen)
return None
def run_full_generation(args: argparse.Namespace, config_path: Path) -> int:
config = load_generation_config(config_path)
root = Path(config["transport_root"])
expected_peer = config["expected_peer_id"]
adapter = args.mtp_adapter
pull = subprocess.run(
[str(adapter), "pull", expected_peer, str(root)],
capture_output=True,
text=True,
check=False,
)
if pull.returncode != 0:
return 3
_, seen = load_request_state(args.state)
selected = select_request(root, seen)
if selected is None:
return 3
correlated_request, request_channel, seen = selected
seen.append(correlated_request)
seen = list(dict.fromkeys(seen))[-SEEN_REQUEST_LIMIT:]
run_id = "sy_" + str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
atomic_json(
args.state,
{
"format": "trainlog-sync-run-report",
"version": 1,
"run_id": run_id,
"request_id": correlated_request,
"request_channel": request_channel,
"seen_request_ids": seen,
"trigger": args.trigger,
"requested_mode": "full_generation_v1",
"effective_mode": "full_generation_v1",
"phase": "requested",
"progress_revision": 1,
"result": "running",
"started_at": now,
"updated_at": now,
},
)
result = subprocess.run(
[
sys.executable,
str(args.orchestrator),
"--database",
str(args.database),
"--state",
str(args.state),
"--config",
str(config_path),
"--run-id",
run_id,
"--request-id",
correlated_request,
],
capture_output=True,
text=True,
check=False,
)
append_log(result.stdout if result.returncode == 0 else result.stderr or result.stdout)
return result.returncode
def main() -> int:
parser = argparse.ArgumentParser()
@ -55,6 +236,25 @@ def main() -> int:
type=Path,
)
parser.add_argument("--trigger", choices=("android", "daemon"), default="android")
parser.add_argument(
"--generation-config",
type=Path,
default=os.environ.get("TRAINLOG_SYNC_GENERATION_CONFIG"),
)
parser.add_argument(
"--orchestrator",
type=Path,
default=Path(__file__).with_name("sync_orchestrator.py"),
)
parser.add_argument("--database", type=Path, default=default_database())
parser.add_argument("--state", type=Path)
parser.add_argument(
"--mtp-adapter",
type=Path,
default=Path(os.environ.get("TRAINLOG_SYNC_MTP_ADAPTER", "")),
)
parser.add_argument(
"--interval",
type=float,
@ -63,11 +263,20 @@ def main() -> int:
args = parser.parse_args()
if args.state is None:
# The Web adapter owns .sync-run.json. The daemon keeps independent
# durable request replay evidence while sharing the database lock.
args.state = Path(str(args.database) + ".syncd-run.json")
if not args.sync_once.exists():
raise SystemExit(
f"sync executable missing: {args.sync_once}"
)
if args.generation_config is not None:
if not args.orchestrator.is_file() or not args.mtp_adapter.is_file():
raise SystemExit("full-generation runtime is incomplete")
signal.signal(
signal.SIGTERM,
request_stop,
@ -83,17 +292,21 @@ def main() -> int:
)
while not STOP:
result = subprocess.run(
[
str(args.sync_once),
"--request-only",
"--trigger",
"android",
],
capture_output=True,
text=True,
check=False,
)
if args.generation_config is not None:
code = run_full_generation(args, args.generation_config)
result = subprocess.CompletedProcess([], code, "", "")
else:
result = subprocess.run(
[
str(args.sync_once),
"--request-only",
"--trigger",
args.trigger,
],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
append_log(

View file

@ -653,6 +653,12 @@ test_generation_mtp = executable(
)
test('generation_mtp', test_generation_mtp)
test(
'syncd_routing',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_syncd_routing.py'],
)
generation_mtp_adapter_double = executable(
'generation-mtp-adapter-double',
'tests/generation_mtp_adapter_double.c',
@ -870,6 +876,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
test(
'sync_peer_worker',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_peer_worker.py'],
)
test(
'sync_drive_transport',
find_program('python3'),

View file

@ -338,14 +338,16 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *
TrainlogStatus status;
status = one_named(io, selection, selection->root, "android-objects", true, &android_objects);
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, android_objects.item_id, "generations", true, &generations);
status =
one_named(io, selection, android_objects.item_id, "generations", true, &generations);
}
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, generations.item_id, generation, true, &selected);
}
if (status != TRAINLOG_STATUS_OK ||
snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 ||
strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= sizeof(path)) {
strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >=
sizeof(path)) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
char parent[1024];
@ -357,7 +359,8 @@ static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *
char objects_path[1024];
if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 ||
strlen(local) + strlen("/android-objects") >= sizeof(objects_path) ||
mkdir_private(objects_path) != TRAINLOG_STATUS_OK || mkdir_private(parent) != TRAINLOG_STATUS_OK) {
mkdir_private(objects_path) != TRAINLOG_STATUS_OK ||
mkdir_private(parent) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects);
@ -587,8 +590,29 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
* remain a normal polling state for the Python conversation.
* INVARIANT: no remote object, ACK, tombstone, or retained generation is
* removed or rewritten by this bounded read. */
status =
pull_named_file(io, &selection, selection.root, "android-peer-v1.json", local, true);
}
if (status == TRAINLOG_STATUS_OK) {
/* WHY: the Android request remains the explicit user/foreground
* admission signal even when the conversation uses full generations.
* CONTRACT: copy the bounded signal only; the orchestrator, not this
* transport adapter, owns correlation and all domain mutations.
* INVARIANT: reading the request never acknowledges or removes it. */
status = pull_named_file(
io, &selection, selection.root, "android-peer-v1.json", local, true);
io, &selection, selection.root, "trainlog-sync-request-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK) {
/* WHY: strict legacy V3 may be unavailable while a complete causal
* generation remains valid. CONTRACT: pull the dedicated bounded
* coordination object without scanning the exchange tree. INVARIANT:
* this read never acknowledges, deletes, or rewrites remote state. */
status = pull_named_file(io,
&selection,
selection.root,
"trainlog-sync-full-generation-request-v1.json",
local,
false);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(

View file

@ -238,6 +238,18 @@ static int test_pull_and_manifest_last(void) {
"android-generation-v1.json",
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
"111111111111\"}");
add(13,
2,
false,
"trainlog-sync-request-v1.json",
"{\"format\":\"trainlog-sync-request\",\"version\":1,\"request_id\":\"sr_11111111-"
"1111-4111-8111-111111111111\",\"requested_at\":\"2026-09-20T12:00:00Z\"}");
add(14,
2,
false,
"trainlog-sync-full-generation-request-v1.json",
"{\"format\":\"trainlog-sync-request\",\"version\":1,\"request_id\":\"sr_11111111-"
"1111-4111-8111-111111111111\",\"requested_at\":\"2026-09-20T12:00:00Z\"}");
add(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
@ -255,6 +267,11 @@ static int test_pull_and_manifest_last(void) {
root,
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json"));
CHECK(access(path, R_OK) == 0);
CHECK(trainlog_test_join_path(
path, sizeof(path), root, "trainlog-sync-full-generation-request-v1.json"));
CHECK(access(path, R_OK) == 0);
CHECK(trainlog_test_join_path(path, sizeof(path), root, "trainlog-sync-request-v1.json"));
CHECK(access(path, R_OK) == 0);
CHECK(trainlog_test_join_path(path, sizeof(path), root, "out"));
CHECK(mkdir(path, 0700) == 0);
char gen[512];

View file

@ -70,6 +70,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
completed: 'Effectuée',
deleted: 'Retirée',
})[state]
const completedCount = program.sessions.filter((session) =>
session.execution_state === 'completed').length
const progress = program.sessions.length === 0
? 0 : Math.round((completedCount / program.sessions.length) * 100)
return <article className="session-detail program-detail">
<div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>
@ -99,6 +103,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
Import {program.source_format} v{program.source_version} · modifié le{' '}
{formatDateTime(program.updated_at, dateFormat)}
</p>
<div className="program-progress" aria-label={`${completedCount} séances effectuées sur ${program.sessions.length}`}>
<div><strong>{completedCount}/{program.sessions.length}</strong><span>{progress}% effectué</span></div>
<progress value={completedCount} max={Math.max(program.sessions.length, 1)} />
</div>
</header>
<ol className="program-session-list">
{program.sessions.map((session) => <li key={session.program_session_id}>

View file

@ -195,17 +195,23 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
}
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
.android-delivery-state.state-acknowledged { color: var(--green); }
.program-list, .program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
.program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
.program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); grid-template-columns: minmax(0, 1fr) 3rem; }
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
.program-session-list { padding: 0; list-style: none; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
.program-session-list li:has(.state-completed) { border-color: color-mix(in srgb, var(--green) 55%, var(--surface-1)); background: color-mix(in srgb, var(--green) 7%, var(--base)); }
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); }
.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
.program-progress { max-width: 28rem; display: grid; gap: .4rem; }
.program-progress div { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.program-progress span { color: var(--subtext-0); font-size: var(--font-size-sm); }
.program-progress progress { width: 100%; height: .55rem; accent-color: var(--green); }
.programs-calendar-page { max-width: 100%; }
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }