Compare commits
No commits in common. "eb6d70fb62a0c726bc5fd7c0f7e022391f8dd6bb" and "ca2ea9379bf234a27842dc08da214d1ca69e37ac" have entirely different histories.
eb6d70fb62
...
ca2ea9379b
16 changed files with 108 additions and 808 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -4,17 +4,6 @@
|
||||||
|
|
||||||
Development opened after the stable v0.1.2 release.
|
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
|
- Hardened direct-MTP full-generation conversations after a private Android
|
||||||
run was frozen before generation capture. Android now emits bounded
|
run was frozen before generation capture. Android now emits bounded
|
||||||
run/phase metadata, refreshes the user-enabled background listener before a
|
run/phase metadata, refreshes the user-enabled background listener before a
|
||||||
|
|
|
||||||
|
|
@ -291,15 +291,10 @@ 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)
|
||||||
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
|
if (!coordinator.hasActionableRequest(local)) {
|
||||||
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)
|
||||||
|
|
@ -327,31 +322,8 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
||||||
stage = "acknowledgement"
|
stage = "acknowledgement"
|
||||||
transport.pushAcknowledgement(local)
|
transport.pushAcknowledgement(local)
|
||||||
},
|
},
|
||||||
intent = SyncConversationIntent.RESUME_BACKGROUND,
|
|
||||||
)
|
)
|
||||||
when (result) {
|
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -76,7 +76,6 @@ 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
|
||||||
|
|
@ -85,36 +84,8 @@ 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)
|
||||||
val actionability =
|
if (coordinator.hasActionableRequest(directory)) {
|
||||||
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
|
coordinator.run(directory, Duration.ofMinutes(5))
|
||||||
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) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,15 +13,8 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
internal enum class SyncConversationIntent {
|
data class Error(val message: String) : ForegroundGenerationResult
|
||||||
NEW_EXPLICIT,
|
|
||||||
RESUME_BACKGROUND,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun interface SyncGenerationTrace {
|
internal fun interface SyncGenerationTrace {
|
||||||
|
|
@ -50,7 +43,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -62,6 +54,14 @@ 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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun request(directory: File): JSONObject? {
|
internal fun hasActionableRequest(directory: File): Boolean {
|
||||||
val path = File(directory, "request-v1.json")
|
val path = File(directory, "request-v1.json")
|
||||||
if (!path.isFile || path.length() !in 1..64 * 1024) return null
|
if (!path.isFile || path.length() !in 1..64 * 1024) return false
|
||||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
|
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
|
||||||
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,64 +102,16 @@ 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 null
|
) return false
|
||||||
return request
|
if (resumableGeneration(runId, desktopPeerId) != null) return true
|
||||||
}
|
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()
|
||||||
|
|
@ -335,10 +287,8 @@ 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()
|
||||||
|
|
@ -362,27 +312,11 @@ 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 {
|
||||||
val claim =
|
if (!conversationLock.tryLock()) {
|
||||||
if (intent == SyncConversationIntent.NEW_EXPLICIT)
|
return ForegroundGenerationResult.Error("Synchronization already in progress.")
|
||||||
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
|
||||||
|
|
@ -391,23 +325,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?.let {
|
afterPeerPublication?.invoke()
|
||||||
it.invoke()
|
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
|
||||||
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_") && when (intent) {
|
runId.startsWith("sy_") &&
|
||||||
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
|
(resumableGeneration(runId) != null ||
|
||||||
SyncConversationIntent.RESUME_BACKGROUND ->
|
!repository.inSyncGenerationTransaction { db ->
|
||||||
resumableGeneration(runId) != null || !hasGeneration(runId)
|
db.rawQuery(
|
||||||
}
|
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||||
}, pollTransport, lease)
|
arrayOf(runId),
|
||||||
|
).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")
|
||||||
|
|
@ -420,7 +354,6 @@ internal class SyncGenerationCoordinator(
|
||||||
it.optString("android_peer_id") == peer
|
it.optString("android_peer_id") == peer
|
||||||
},
|
},
|
||||||
pollTransport,
|
pollTransport,
|
||||||
lease,
|
|
||||||
)
|
)
|
||||||
reconcileArchiveAcknowledgements(
|
reconcileArchiveAcknowledgements(
|
||||||
archiveAckRaw,
|
archiveAckRaw,
|
||||||
|
|
@ -437,7 +370,6 @@ 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"),
|
||||||
|
|
@ -447,13 +379,11 @@ 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",
|
||||||
|
|
@ -480,7 +410,6 @@ 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.
|
||||||
|
|
@ -488,13 +417,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, lease)
|
}, pollTransport)
|
||||||
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, lease)
|
}, pollTransport)
|
||||||
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||||
phase(
|
phase(
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -529,13 +458,6 @@ 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 {
|
||||||
|
|
@ -564,13 +486,9 @@ internal class SyncGenerationCoordinator(
|
||||||
"error",
|
"error",
|
||||||
result = error.javaClass.simpleName,
|
result = error.javaClass.simpleName,
|
||||||
)
|
)
|
||||||
ForegroundGenerationResult.Failed(
|
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||||
error.message ?: "generation synchronization failed",
|
|
||||||
currentRunId,
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
lease.close()
|
conversationLock.unlock()
|
||||||
claim?.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ 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
|
||||||
|
|
@ -55,72 +52,39 @@ 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) {
|
||||||
try {
|
val opened = exporter.openStorageSnapshot()
|
||||||
val opened = exporter.openStorageSnapshot()
|
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
||||||
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
|
||||||
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
|
}
|
||||||
}
|
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
|
||||||
val snapshot =
|
/* CONTRACT: the request signal remains compatible with an unconfigured
|
||||||
(opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
|
* desktop, so publish the strict standalone V3 snapshot before admission.
|
||||||
/* CONTRACT: the request signal remains compatible with an unconfigured
|
* A configured daemon still routes the signal exclusively to the complete
|
||||||
* desktop, so publish the strict standalone V3 snapshot before admission.
|
* generation below; this compatibility publication never selects V3. */
|
||||||
* A configured daemon still routes the signal exclusively to the complete
|
when (val publication = exporter.exportMobileBundle(snapshot)) {
|
||||||
* generation below; this compatibility publication never selects V3. */
|
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
|
||||||
when (val publication = exporter.exportMobileBundle(snapshot)) {
|
is SyncExportResult.Error ->
|
||||||
SyncExportResult.Unsupported -> return@withContext SyncRequestResult.Unsupported
|
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
|
||||||
is SyncExportResult.Error ->
|
is SyncExportResult.Exported -> Unit
|
||||||
return@withContext SyncRequestResult.Error("prepare:${publication.message}")
|
}
|
||||||
is SyncExportResult.Exported -> Unit
|
return@withContext when (
|
||||||
}
|
val result = SyncGenerationForegroundCoordinator(repository, visibility).run(
|
||||||
val directory = canonicalExchangeDirectory()
|
canonicalExchangeDirectory(),
|
||||||
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
|
afterPeerPublication = {
|
||||||
return@withContext when (
|
when (val request = requestOutbox.requestSync(snapshot)) {
|
||||||
val result = coordinator.run(
|
is SyncRequestResult.Requested -> Unit
|
||||||
directory,
|
SyncRequestResult.Unsupported ->
|
||||||
afterPeerPublication = {
|
throw IllegalStateException("Generation synchronization is unsupported.")
|
||||||
when (val request = requestOutbox.requestSync(snapshot)) {
|
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
|
||||||
is SyncRequestResult.Requested -> Unit
|
is SyncRequestResult.Completed ->
|
||||||
SyncRequestResult.Unsupported ->
|
throw IllegalStateException("Unexpected completed request signal.")
|
||||||
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 ->
|
) {
|
||||||
SyncRequestResult.Error(result.message).also {
|
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||||
result.runId?.let { runId ->
|
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||||
terminalLedger?.recordTerminal(
|
|
||||||
runId,
|
|
||||||
coordinator.remoteEvidence(directory, runId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
explicitClaim.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,11 +147,8 @@ 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 = when {
|
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
|
||||||
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
|
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
|
||||||
raw.startsWith("prepare:") ->
|
|
||||||
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
|
|
||||||
else -> raw
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var status by
|
var status by
|
||||||
|
|
@ -432,15 +393,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. */
|
||||||
/* WHY: Samsung may freeze the activity after USB mode
|
if (backgroundSettings.enabled) {
|
||||||
* changes or a permission surface opens. CONTRACT:
|
/* WHY: Samsung may freeze the activity after USB mode
|
||||||
* announce explicit priority before waking the service.
|
* changes or a permission surface opens. CONTRACT: a
|
||||||
* The service may keep Android alive, but cannot become
|
* user-enabled foreground listener is refreshed before
|
||||||
* conversation owner while this claim is pending. */
|
* the UI-owned conversation begins; the process lock
|
||||||
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
|
* prevents it from creating a second generation. */
|
||||||
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
|
SyncBackgroundService.start(context)
|
||||||
|
}
|
||||||
syncRunning = true
|
syncRunning = true
|
||||||
status = strings.getString(R.string.sync_running)
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
when (
|
when (
|
||||||
|
|
@ -449,8 +410,6 @@ fun SyncScreen(
|
||||||
exporter,
|
exporter,
|
||||||
requestOutbox,
|
requestOutbox,
|
||||||
AndroidMtpPublicationVisibility(context),
|
AndroidMtpPublicationVisibility(context),
|
||||||
explicitClaim,
|
|
||||||
BackgroundSyncRunLedger(context),
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is SyncRequestResult.Completed -> {
|
is SyncRequestResult.Completed -> {
|
||||||
|
|
@ -476,7 +435,6 @@ fun SyncScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
explicitClaim.close()
|
|
||||||
syncRunning = false
|
syncRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -38,8 +38,12 @@ 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))
|
||||||
assertEquals(ForegroundGenerationResult.Busy, duplicate)
|
assertTrue(duplicate is ForegroundGenerationResult.Error)
|
||||||
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
|
assertEquals(
|
||||||
|
"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())
|
||||||
|
|
|
||||||
|
|
@ -357,15 +357,9 @@ class SyncGenerationServiceTest {
|
||||||
val repository = TrainlogRepository(context, databaseName)
|
val repository = TrainlogRepository(context, databaseName)
|
||||||
try {
|
try {
|
||||||
val coordinator = SyncGenerationCoordinator(repository)
|
val coordinator = SyncGenerationCoordinator(repository)
|
||||||
assertEquals(
|
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
|
||||||
coordinator.classifyBackgroundRequest(root, null),
|
|
||||||
)
|
|
||||||
File(root, "request-v1.json").writeText("not-json")
|
File(root, "request-v1.json").writeText("not-json")
|
||||||
assertEquals(
|
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
|
||||||
coordinator.classifyBackgroundRequest(root, null),
|
|
||||||
)
|
|
||||||
|
|
||||||
val peer = SyncGenerationService(repository).peerId()
|
val peer = SyncGenerationService(repository).peerId()
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -376,17 +370,11 @@ 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(
|
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||||
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(
|
assertEquals(true, coordinator.hasActionableRequest(root))
|
||||||
BackgroundRequestActionability.NEW_REQUEST,
|
|
||||||
coordinator.classifyBackgroundRequest(root, null),
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(databaseName)
|
context.deleteDatabase(databaseName)
|
||||||
|
|
|
||||||
|
|
@ -1077,24 +1077,11 @@ Manifest/reference remain the commit markers; scanning does not change their
|
||||||
bytes or protocol meaning. Production logging records only `run_id`, phase,
|
bytes or protocol meaning. Production logging records only `run_id`, phase,
|
||||||
artifact name, generation ID and result.
|
artifact name, generation ID and result.
|
||||||
|
|
||||||
When background synchronization is enabled, an explicit UI request announces
|
When background synchronization is enabled, an explicit UI request refreshes
|
||||||
foreground ownership before refreshing the foreground listener. Service
|
the foreground listener before starting the UI-owned conversation. The shared
|
||||||
liveness and conversation ownership are separate: the service may keep the
|
process lock prevents a second run while the UI coordinator is alive; if the
|
||||||
process alive, but it cannot acquire automatic ownership while explicit intent
|
activity is frozen or destroyed, the listener can validate and resume the
|
||||||
is pending. The process-wide arbiter gives `NEW_EXPLICIT` priority over
|
durable request without inventing another generation.
|
||||||
`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
|
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
|
normal phone widths. The deployed 1080 px/480 dpi phone supplies approximately
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,7 @@ basis without rolling back USB. Both transports move the same generation/ACK
|
||||||
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
||||||
USB listener with mandatory notification and a private-folder Drive grant with
|
USB listener with mandatory notification and a private-folder Drive grant with
|
||||||
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
||||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
coordinator as SyncScreen.
|
||||||
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.
|
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||||
|
|
@ -27,18 +20,13 @@ individual adapter budget is distinct from the overall conversation deadline,
|
||||||
so an absent correlated generation reports a protocol wait timeout while an
|
so an absent correlated generation reports a protocol wait timeout while an
|
||||||
actually blocked adapter reports `transport_timeout`.
|
actually blocked adapter reports `transport_timeout`.
|
||||||
|
|
||||||
The resumed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
|
The failed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
|
||||||
desktop request and archive-ACK files, captured generation
|
desktop request and archive-ACK files on the Android filesystem, but created no
|
||||||
`gen_532da95e-713f-4163-acc1-d838673c33bf`, and published its immutable objects
|
Android generation row/reference/object. System evidence shows the activity
|
||||||
and reference with manifest SHA-256
|
was moved to Android's file-access settings and frozen before capture. The
|
||||||
`170b55ad5bf3e44501fb506107cd26ab807d0179c95a5f1bf73f8251ea1a14e8`.
|
retained Android generation reference and MTP bytes both belonged to an older
|
||||||
Filesystem and MTP exposed the same bytes. It then waited for a desktop ACK
|
run; no hidden new generation was found. No causal or business mutation
|
||||||
that could not arrive because the daemon correctly refused to create another
|
occurred.
|
||||||
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
|
Existing preparation rows expose separate editing and Android-delivery states
|
||||||
and an accessible send action. Draft/local advances the same preparation to a
|
and an accessible send action. Draft/local advances the same preparation to a
|
||||||
|
|
|
||||||
|
|
@ -1555,24 +1555,14 @@ 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`
|
layout as one column, 1.5 font-scale fallback, long French labels and `kg`/`cm`
|
||||||
suffixes.
|
suffixes.
|
||||||
|
|
||||||
Android conversation-ownership regressions reproduce the production race: an
|
Read-only forensics for failed run
|
||||||
automatic owner waiting on an old resumable run yields to explicit foreground
|
`sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` found the correlated desktop request
|
||||||
intent, the click publishes a fresh request UUID, the explicit coordinator
|
and archive acknowledgements on Android, but no correlated generation
|
||||||
ignores the pre-click desktop run, and exactly one generation is captured for
|
reference, immutable directory or error record. The direct filesystem and MTP
|
||||||
the new run. Additional fixtures cover typed cancellation and durable handoff,
|
views contained identical bytes for the retained older Android reference.
|
||||||
terminal-run suppression across five scheduling cycles, reactivation by new
|
Logcat showed Android moving Trainlog behind the file-access settings surface
|
||||||
correlated desktop evidence, and exclusive ownership. Desktop routing tests
|
and freezing it before capture. The post-v19 backup predates this run and is
|
||||||
retain the complementary invariant that an already-consumed Android request
|
not presented as SQLite evidence for it.
|
||||||
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
|
The authorized 2026-09-17 rollout added regression coverage for production
|
||||||
failures found only after installation: feedback import under `sqlite3.Row`,
|
failures found only after installation: feedback import under `sqlite3.Row`,
|
||||||
|
|
|
||||||
|
|
@ -74,58 +74,6 @@ 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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue