fix(android): prioritize explicit sync conversations

This commit is contained in:
fy59 2026-09-20 17:54:43 +02:00
parent ca2ea9379b
commit 38d304d014
12 changed files with 741 additions and 87 deletions

View file

@ -291,10 +291,15 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
transport.pull(local) transport.pull(local)
stage = "coordinate" stage = "coordinate"
val coordinator = SyncGenerationCoordinator(repository) val coordinator = SyncGenerationCoordinator(repository)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
coordinator.publishPeer(local) coordinator.publishPeer(local)
stage = "peer-publication" stage = "peer-publication"
transport.pushPeer(local) 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)) { if (coordinator.hasReplayableAcknowledgement(local)) {
stage = "acknowledgement-replay" stage = "acknowledgement-replay"
transport.pushAcknowledgement(local) transport.pushAcknowledgement(local)
@ -322,8 +327,31 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
stage = "acknowledgement" stage = "acknowledgement"
transport.pushAcknowledgement(local) 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) { } catch (error: Exception) {
/* SECURITY: stage and exception class are fixed application /* SECURITY: stage and exception class are fixed application
* metadata. Provider messages may contain private URIs and are * 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), requireNotNull(repository),
AndroidMtpPublicationVisibility(applicationContext), AndroidMtpPublicationVisibility(applicationContext),
) )
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
while (!stopped.get() && BackgroundSyncSettings(this).enabled) { while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory() val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation /* WHY: waiting inside run() owns the process-wide conversation
@ -84,8 +85,36 @@ class SyncBackgroundService : Service() {
* the coordinator still validates all protocol evidence. * the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */ * INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory) coordinator.publishPeer(directory)
if (coordinator.hasActionableRequest(directory)) { val actionability =
coordinator.run(directory, Duration.ofMinutes(5)) 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) } 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 { internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : 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 { internal fun interface SyncGenerationTrace {
@ -43,6 +50,7 @@ internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository, private val repository: TrainlogRepository,
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility, private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace, private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
) { ) {
private val service = SyncGenerationService(repository) private val service = SyncGenerationService(repository)
@ -54,14 +62,6 @@ internal class SyncGenerationCoordinator(
result: String? = null, result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result) ) = 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? = internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db -> repository.inSyncGenerationTransaction { db ->
db.rawQuery( 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") val path = File(directory, "request-v1.json")
if (!path.isFile || path.length() !in 1..64 * 1024) return false if (!path.isFile || path.length() !in 1..64 * 1024) return null
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
val runId = request.optString("run_id") val runId = request.optString("run_id")
val desktopPeerId = request.optString("desktop_peer_id") val desktopPeerId = request.optString("desktop_peer_id")
if ( if (
@ -102,16 +102,64 @@ internal class SyncGenerationCoordinator(
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) || !runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) || !desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
request.optString("android_peer_id") != service.peerId() request.optString("android_peer_id") != service.peerId()
) return false ) return null
if (resumableGeneration(runId, desktopPeerId) != null) return true return request
return !repository.inSyncGenerationTransaction { db -> }
private fun hasGeneration(runId: String): Boolean =
repository.inSyncGenerationTransaction { db ->
db.rawQuery( db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1", "SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId), arrayOf(runId),
).use { it.moveToFirst() } ).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 { internal fun hasReplayableAcknowledgement(directory: File): Boolean {
val request = val request =
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull() runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
@ -287,8 +335,10 @@ internal class SyncGenerationCoordinator(
deadline: Long, deadline: Long,
predicate: (JSONObject) -> Boolean, predicate: (JSONObject) -> Boolean,
pump: (() -> Unit)? = null, pump: (() -> Unit)? = null,
lease: SyncConversationArbiter.Lease,
): ByteArray { ): ByteArray {
while (true) { while (true) {
lease.throwIfYieldRequested()
if (path.isFile && path.length() <= 64 * 1024) { if (path.isFile && path.length() <= 64 * 1024) {
try { try {
val raw = path.readBytes() val raw = path.readBytes()
@ -312,11 +362,27 @@ internal class SyncGenerationCoordinator(
afterPublication: (() -> Unit)? = null, afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null, pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null, afterAcknowledgement: (() -> Unit)? = null,
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
): ForegroundGenerationResult { ): ForegroundGenerationResult {
if (!conversationLock.tryLock()) { val claim =
return ForegroundGenerationResult.Error("Synchronization already in progress.") 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 { return try {
val baselineRunId = request(directory)?.optString("run_id")
val peer = publishPeer(directory) val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json") phase(null, "peer_published", "android-peer-v1.json")
/* WHY: trainlog-syncd is intentionally driven by the established /* 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. * identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and * INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */ * ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke() afterPeerPublication?.let {
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json") it.invoke()
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
}
val deadline = System.nanoTime() + timeout.toNanos() val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw = val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate -> awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
val runId = candidate.optString("run_id") val runId = candidate.optString("run_id")
runId.startsWith("sy_") && runId.startsWith("sy_") && when (intent) {
(resumableGeneration(runId) != null || SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
!repository.inSyncGenerationTransaction { db -> SyncConversationIntent.RESUME_BACKGROUND ->
db.rawQuery( resumableGeneration(runId) != null || !hasGeneration(runId)
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1", }
arrayOf(runId), }, pollTransport, lease)
).use { it.moveToFirst() }
})
}, pollTransport)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8)) val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
val runId = request.getString("run_id") val runId = request.getString("run_id")
currentRunId = runId
phase(runId, "generation_request_observed", "request-v1.json") phase(runId, "generation_request_observed", "request-v1.json")
if (request.getString("android_peer_id") != peer) if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer") throw SyncGenerationException("request targets another Android peer")
@ -354,6 +420,7 @@ internal class SyncGenerationCoordinator(
it.optString("android_peer_id") == peer it.optString("android_peer_id") == peer
}, },
pollTransport, pollTransport,
lease,
) )
reconcileArchiveAcknowledgements( reconcileArchiveAcknowledgements(
archiveAckRaw, archiveAckRaw,
@ -370,6 +437,7 @@ internal class SyncGenerationCoordinator(
* an acknowledged outbound generation remains resumable only to * an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */ * finish its missing inbound half. */
phase(runId, "generation_capture_started") phase(runId, "generation_capture_started")
lease.throwIfYieldRequested()
val captured = val captured =
resumableGeneration( resumableGeneration(
request.getString("run_id"), request.getString("run_id"),
@ -379,11 +447,13 @@ internal class SyncGenerationCoordinator(
request.getString("desktop_peer_id"), request.getString("desktop_peer_id"),
request.getString("run_id"), request.getString("run_id"),
) )
lease.throwIfYieldRequested()
phase(runId, "generation_captured", generationId = captured.generationId) phase(runId, "generation_captured", generationId = captured.generationId)
val published = service.publish(captured, File(directory, "android-objects")) val published = service.publish(captured, File(directory, "android-objects"))
visibility.confirm( visibility.confirm(
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList() published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
) )
lease.throwIfYieldRequested()
phase( phase(
runId, runId,
"generation_objects_published", "generation_objects_published",
@ -410,6 +480,7 @@ internal class SyncGenerationCoordinator(
captured.generationId, captured.generationId,
) )
afterPublication?.invoke() afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in // CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK // the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored. // 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, { awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId && it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId it.optString("generation_id") == captured.generationId
}, pollTransport) }, pollTransport, lease)
service.acceptAcknowledgement(desktopAck) service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId) phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
val referenceRaw = val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, { awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId it.optString("run_id") == captured.runId
}, pollTransport) }, pollTransport, lease)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8)) val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
phase( phase(
runId, runId,
@ -458,6 +529,13 @@ internal class SyncGenerationCoordinator(
afterAcknowledgement?.invoke() afterAcknowledgement?.invoke()
phase(runId, "completed", result = "completed") phase(runId, "completed", result = "completed")
ForegroundGenerationResult.Completed(captured.runId) 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) { } catch (error: Exception) {
if (error.message == "generation retention capacity exhausted") { if (error.message == "generation retention capacity exhausted") {
try { try {
@ -486,9 +564,13 @@ internal class SyncGenerationCoordinator(
"error", "error",
result = error.javaClass.simpleName, result = error.javaClass.simpleName,
) )
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed") ForegroundGenerationResult.Failed(
error.message ?: "generation synchronization failed",
currentRunId,
)
} finally { } 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.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult 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.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.BackgroundSyncSettings import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService import com.labfytools.trainlog.data.SyncBackgroundService
@ -52,39 +55,72 @@ internal suspend fun publishBundleAndRequest(
exporter: SyncExporter, exporter: SyncExporter,
requestOutbox: SyncRequestOutbox, requestOutbox: SyncRequestOutbox,
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility, visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
explicitClaim: SyncConversationArbiter.ExplicitClaim =
SyncConversationArbiter.process.announceExplicit(),
terminalLedger: BackgroundSyncRunLedger? = null,
): SyncRequestResult = withContext(Dispatchers.IO) { ): SyncRequestResult = withContext(Dispatchers.IO) {
val opened = exporter.openStorageSnapshot() try {
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) { val opened = exporter.openStorageSnapshot()
return@withContext SyncRequestResult.Error("prepare:${opened.message}") 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 val snapshot =
* desktop, so publish the strict standalone V3 snapshot before admission. (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
* A configured daemon still routes the signal exclusively to the complete /* CONTRACT: the request signal remains compatible with an unconfigured
* generation below; this compatibility publication never selects V3. */ * desktop, so publish the strict standalone V3 snapshot before admission.
when (val publication = exporter.exportMobileBundle(snapshot)) { * A configured daemon still routes the signal exclusively to the complete
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported * generation below; this compatibility publication never selects V3. */
is SyncExportResult.Error -> when (val publication = exporter.exportMobileBundle(snapshot)) {
return@withContext SyncRequestResult.Error("prepare:${publication.message}") SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
is SyncExportResult.Exported -> Unit is SyncExportResult.Error ->
} return@withContext SyncRequestResult.Error("prepare:${publication.message}")
return@withContext when ( is SyncExportResult.Exported -> Unit
val result = SyncGenerationForegroundCoordinator(repository, visibility).run( }
canonicalExchangeDirectory(), val directory = canonicalExchangeDirectory()
afterPeerPublication = { val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
when (val request = requestOutbox.requestSync(snapshot)) { return@withContext when (
is SyncRequestResult.Requested -> Unit val result = coordinator.run(
SyncRequestResult.Unsupported -> directory,
throw IllegalStateException("Generation synchronization is unsupported.") afterPeerPublication = {
is SyncRequestResult.Error -> throw IllegalStateException(request.message) when (val request = requestOutbox.requestSync(snapshot)) {
is SyncRequestResult.Completed -> is SyncRequestResult.Requested -> Unit
throw IllegalStateException("Unexpected completed request signal.") 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.Cancelled ->
) SyncRequestResult.Error("sync_interrupted")
) { is ForegroundGenerationResult.Failed ->
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId) SyncRequestResult.Error(result.message).also {
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message) 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)) add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) } skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
}.joinToString(", ") }.joinToString(", ")
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let { fun visibleError(raw: String): String = when {
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw 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 var status by
@ -393,15 +432,15 @@ fun SyncScreen(
* published from one prepared repository snapshot before * published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a * that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */ * fresh V3 file with stale or absent causal companions. */
if (backgroundSettings.enabled) { /* WHY: Samsung may freeze the activity after USB mode
/* WHY: Samsung may freeze the activity after USB mode * changes or a permission surface opens. CONTRACT:
* changes or a permission surface opens. CONTRACT: a * announce explicit priority before waking the service.
* user-enabled foreground listener is refreshed before * The service may keep Android alive, but cannot become
* the UI-owned conversation begins; the process lock * conversation owner while this claim is pending. */
* prevents it from creating a second generation. */ val explicitClaim = SyncConversationArbiter.process.announceExplicit()
SyncBackgroundService.start(context) if (backgroundSettings.enabled) SyncBackgroundService.start(context)
}
syncRunning = true syncRunning = true
status = strings.getString(R.string.sync_running)
coroutineScope.launch { coroutineScope.launch {
try { try {
when ( when (
@ -410,6 +449,8 @@ fun SyncScreen(
exporter, exporter,
requestOutbox, requestOutbox,
AndroidMtpPublicationVisibility(context), AndroidMtpPublicationVisibility(context),
explicitClaim,
BackgroundSyncRunLedger(context),
) )
) { ) {
is SyncRequestResult.Completed -> { is SyncRequestResult.Completed -> {
@ -435,6 +476,7 @@ fun SyncScreen(
} }
} }
} finally { } finally {
explicitClaim.close()
syncRunning = false syncRunning = false
} }
} }

View file

@ -94,6 +94,7 @@
<string name="sync_action">Synchronize</string> <string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string> <string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</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="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</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> <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_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string> <string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</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="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</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> <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)) assertTrue(entered.await(2, TimeUnit.SECONDS))
val duplicate = background.run(root, Duration.ofMillis(10)) val duplicate = background.run(root, Duration.ofMillis(10))
assertTrue(duplicate is ForegroundGenerationResult.Error) assertEquals(ForegroundGenerationResult.Busy, duplicate)
assertEquals( assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
"Synchronization already in progress.",
(duplicate as ForegroundGenerationResult.Error).message,
)
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Error)
repository.inSyncGenerationTransaction { database -> repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor -> database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

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

View file

@ -74,6 +74,58 @@ class SyncdRoutingTest(unittest.TestCase):
self.assertEqual(trigger, state["trigger"]) self.assertEqual(trigger, state["trigger"])
self.assertEqual("full_generation_v1", state["effective_mode"]) 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): 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() 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( production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(