Compare commits

..

2 commits

Author SHA1 Message Date
fy59
eb6d70fb62 docs(sync): document Android conversation ownership 2026-09-20 17:54:47 +02:00
fy59
38d304d014 fix(android): prioritize explicit sync conversations 2026-09-20 17:54:43 +02:00
16 changed files with 808 additions and 108 deletions

View file

@ -4,6 +4,17 @@
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

View file

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

View file

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

View file

@ -76,6 +76,7 @@ class SyncBackgroundService : Service() {
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
@ -84,8 +85,36 @@ class SyncBackgroundService : Service() {
* the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory)
if (coordinator.hasActionableRequest(directory)) {
coordinator.run(directory, Duration.ofMinutes(5))
val actionability =
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NEW_REQUEST ||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
) {
when (
val result = coordinator.run(
directory,
Duration.ofMinutes(5),
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
) {
is ForegroundGenerationResult.Completed ->
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(directory, result.runId),
)
is ForegroundGenerationResult.Failed ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
is ForegroundGenerationResult.Superseded ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Unit
}
}
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}

View file

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

View file

@ -13,8 +13,15 @@ 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 {
@ -43,6 +50,7 @@ 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)
@ -54,14 +62,6 @@ internal class SyncGenerationCoordinator(
result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result)
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()
}
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
@ -90,10 +90,10 @@ internal class SyncGenerationCoordinator(
}
}
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 (
@ -102,16 +102,64 @@ internal class SyncGenerationCoordinator(
!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()
@ -287,8 +335,10 @@ internal class SyncGenerationCoordinator(
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()
@ -312,11 +362,27 @@ internal class SyncGenerationCoordinator(
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 baselineRunId = request(directory)?.optString("run_id")
val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json")
/* WHY: trainlog-syncd is intentionally driven by the established
@ -325,23 +391,23 @@ internal class SyncGenerationCoordinator(
* 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()
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
afterPeerPublication?.let {
it.invoke()
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
}
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")
@ -354,6 +420,7 @@ internal class SyncGenerationCoordinator(
it.optString("android_peer_id") == peer
},
pollTransport,
lease,
)
reconcileArchiveAcknowledgements(
archiveAckRaw,
@ -370,6 +437,7 @@ internal class SyncGenerationCoordinator(
* 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"),
@ -379,11 +447,13 @@ internal class SyncGenerationCoordinator(
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",
@ -410,6 +480,7 @@ internal class SyncGenerationCoordinator(
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.
@ -417,13 +488,13 @@ internal class SyncGenerationCoordinator(
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,
@ -458,6 +529,13 @@ internal class SyncGenerationCoordinator(
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 {
@ -486,9 +564,13 @@ internal class SyncGenerationCoordinator(
"error",
result = error.javaClass.simpleName,
)
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
ForegroundGenerationResult.Failed(
error.message ?: "generation synchronization failed",
currentRunId,
)
} finally {
conversationLock.unlock()
lease.close()
claim?.close()
}
}
}

View file

@ -29,6 +29,9 @@ 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
@ -52,39 +55,72 @@ internal suspend fun publishBundleAndRequest(
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 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
/* CONTRACT: the request signal remains compatible with an unconfigured
* desktop, so publish the strict standalone V3 snapshot before admission.
* A configured daemon still routes the signal exclusively to the complete
* generation below; this compatibility publication never selects V3. */
when (val publication = exporter.exportMobileBundle(snapshot)) {
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
is SyncExportResult.Error ->
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
is SyncExportResult.Exported -> Unit
}
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository, visibility).run(
canonicalExchangeDirectory(),
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
try {
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
/* CONTRACT: the request signal remains compatible with an unconfigured
* desktop, so publish the strict standalone V3 snapshot before admission.
* A configured daemon still routes the signal exclusively to the complete
* generation below; this compatibility publication never selects V3. */
when (val publication = exporter.exportMobileBundle(snapshot)) {
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
is SyncExportResult.Error ->
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
is SyncExportResult.Exported -> Unit
}
val directory = canonicalExchangeDirectory()
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
return@withContext when (
val result = coordinator.run(
directory,
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = explicitClaim,
)
) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
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.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
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()
}
}
@ -147,8 +183,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
@ -393,15 +432,15 @@ 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. */
if (backgroundSettings.enabled) {
/* WHY: Samsung may freeze the activity after USB mode
* changes or a permission surface opens. CONTRACT: a
* user-enabled foreground listener is refreshed before
* the UI-owned conversation begins; the process lock
* prevents it from creating a second generation. */
SyncBackgroundService.start(context)
}
/* 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 (
@ -410,6 +449,8 @@ fun SyncScreen(
exporter,
requestOutbox,
AndroidMtpPublicationVisibility(context),
explicitClaim,
BackgroundSyncRunLedger(context),
)
) {
is SyncRequestResult.Completed -> {
@ -435,6 +476,7 @@ fun SyncScreen(
}
}
} finally {
explicitClaim.close()
syncRunning = false
}
}

View file

@ -94,6 +94,7 @@
<string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
<string name="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</string>
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>

View file

@ -94,6 +94,7 @@
<string name="sync_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</string>
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>

View file

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

View file

@ -38,12 +38,8 @@ class SyncGenerationConversationLockTest {
}
assertTrue(entered.await(2, TimeUnit.SECONDS))
val duplicate = background.run(root, Duration.ofMillis(10))
assertTrue(duplicate is ForegroundGenerationResult.Error)
assertEquals(
"Synchronization already in progress.",
(duplicate as ForegroundGenerationResult.Error).message,
)
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Error)
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())

View file

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

View file

@ -1077,11 +1077,24 @@ 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 refreshes
the foreground listener before starting the UI-owned conversation. The shared
process lock prevents a second run while the UI coordinator is alive; if the
activity is frozen or destroyed, the listener can validate and resume the
durable request without inventing another generation.
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

View file

@ -7,7 +7,14 @@ 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
@ -20,13 +27,18 @@ 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 failed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
desktop request and archive-ACK files on the Android filesystem, but created no
Android generation row/reference/object. System evidence shows the activity
was moved to Android's file-access settings and frozen before capture. The
retained Android generation reference and MTP bytes both belonged to an older
run; no hidden new generation was found. No causal or business mutation
occurred.
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

View file

@ -1555,14 +1555,24 @@ 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.
Read-only forensics for failed run
`sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` found the correlated desktop request
and archive acknowledgements on Android, but no correlated generation
reference, immutable directory or error record. The direct filesystem and MTP
views contained identical bytes for the retained older Android reference.
Logcat showed Android moving Trainlog behind the file-access settings surface
and freezing it before capture. The post-v19 backup predates this run and is
not presented as SQLite evidence for it.
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`,

View file

@ -74,6 +74,58 @@ class SyncdRoutingTest(unittest.TestCase):
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_trigger_no_longer_uses_opt_in_or_legacy_export(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(