Compare commits
17 commits
88e0eb0fb7
...
0262433615
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0262433615 | ||
|
|
ab3986200e | ||
|
|
eb6d70fb62 | ||
|
|
38d304d014 | ||
|
|
ca2ea9379b | ||
|
|
0e13c3947a | ||
|
|
60b08f225a | ||
|
|
a2eaf224e8 | ||
|
|
ba5021b436 | ||
|
|
99e428c558 | ||
|
|
2a68e5a505 | ||
|
|
1e9c8aa741 | ||
|
|
f27ef5af5f | ||
|
|
53baa7ad24 | ||
|
|
e41949557b | ||
|
|
d56bd3b54f | ||
|
|
cb4010dbd5 |
56 changed files with 2773 additions and 546 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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++ },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") })
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
191
tests/test_syncd_routing.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue