Compare commits

..

4 commits

19 changed files with 666 additions and 42 deletions

View file

@ -4,6 +4,19 @@
Development opened after the stable v0.1.2 release.
- Hardened direct-MTP full-generation conversations after a private Android
run was frozen before generation capture. Android now emits bounded
run/phase metadata, refreshes the user-enabled background listener before a
foreground request, and confirms MediaProvider/MTP visibility for immutable
generation objects before publishing their reference. Desktop MTP pulls are
throttled to one complete refresh every three seconds, keep a separate
30-second operation budget, and report expiration of the conversation as a
correlated protocol timeout instead of a misleading final one-second
transport timeout. Explicit requests continue to receive fresh UUIDs.
Mensurations now retains two compact columns at the deployed phone's 312 dp
content width, with a narrow/large-font fallback. Schemas, causal semantics,
generation formats, ACK formats and standalone V3 strictness are unchanged.
- Routed Android foreground and daemon-triggered daily synchronization through
the existing full-generation orchestrator whenever its trusted local
configuration is present. The standalone mobile V3 importer remains strict

View file

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

View file

@ -72,7 +72,10 @@ class SyncBackgroundService : Service() {
}
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
val coordinator = SyncGenerationCoordinator(
requireNotNull(repository),
AndroidMtpPublicationVisibility(applicationContext),
)
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation

View file

@ -1,5 +1,6 @@
package com.labfytools.trainlog.data
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
@ -16,10 +17,43 @@ internal sealed interface ForegroundGenerationResult {
data class Error(val message: String) : ForegroundGenerationResult
}
internal fun interface SyncGenerationTrace {
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
}
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
override fun record(
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
) {
Log.i(
"TrainlogSyncGeneration",
listOfNotNull(
"run_id=${runId ?: "pending"}",
"phase=$phase",
artifact?.let { "artifact=$it" },
generationId?.let { "generation_id=$it" },
result?.let { "result=$it" },
).joinToString(" "),
)
}
}
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
) {
private val service = SyncGenerationService(repository)
private fun phase(
runId: String?,
phase: String,
artifact: String? = null,
generationId: String? = null,
result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result)
companion object {
/* WHY: UI and background triggers share one repository protocol and
* must never create concurrent runs. CONTRACT: this process-wide lock
@ -229,9 +263,12 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* an identical canonical descriptor is already a complete durable
* publication. INVARIANT: changed or unreadable bytes still use the
* crash-safe replacement path above. */
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
visibility.confirm(listOf(path))
return peer
}
publish(path, descriptor)
visibility.confirm(listOf(path))
return peer
}
@ -281,6 +318,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
}
return try {
val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json")
/* WHY: trainlog-syncd is intentionally driven by the established
* Android request artifact, not by polling generation internals.
* CONTRACT: each explicit foreground attempt publishes the peer
@ -288,6 +326,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke()
phase(null, "legacy_trigger_published", "trainlog-sync-request-v1.json")
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
@ -302,6 +341,8 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
})
}, pollTransport)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
val runId = request.getString("run_id")
phase(runId, "generation_request_observed", "request-v1.json")
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val archiveAckRaw =
@ -320,6 +361,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
peer,
request.getString("desktop_peer_id"),
)
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
@ -327,6 +369,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
phase(runId, "generation_capture_started")
val captured =
resumableGeneration(
request.getString("run_id"),
@ -336,9 +379,20 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
phase(runId, "generation_captured", generationId = captured.generationId)
val published = service.publish(captured, File(directory, "android-objects"))
visibility.confirm(
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
)
phase(
runId,
"generation_objects_published",
published.relativeTo(directory).path,
captured.generationId,
)
val generationReference = File(directory, "android-generation-v1.json")
publish(
File(directory, "android-generation-v1.json"),
generationReference,
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
@ -348,6 +402,13 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
visibility.confirm(listOf(generationReference))
phase(
runId,
"generation_reference_published",
generationReference.name,
captured.generationId,
)
afterPublication?.invoke()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
@ -358,11 +419,18 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
it.optString("generation_id") == captured.generationId
}, pollTransport)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId
}, pollTransport)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
phase(
runId,
"desktop_generation_observed",
"desktop-generation-v1.json",
reference.optString("generation_id"),
)
/* WHY: non-atomic transports can expose a newly downloaded
* reference locally before the corresponding immutable objects
* have reached this process. CONTRACT: correlation validates the
@ -373,8 +441,22 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
pollTransport?.invoke()
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
phase(
runId,
"desktop_generation_consumed",
generationId = reference.optString("generation_id"),
)
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
publish(acknowledgementPath, acknowledgement)
visibility.confirm(listOf(acknowledgementPath))
phase(
runId,
"android_ack_published",
acknowledgementPath.name,
reference.optString("generation_id"),
)
afterAcknowledgement?.invoke()
phase(runId, "completed", result = "completed")
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
if (error.message == "generation retention capacity exhausted") {
@ -391,11 +473,19 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("action", "Archive acknowledged generations, then retry explicitly.")
.toString(),
)
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
} catch (_: Exception) {
// Preserve the original local failure when error reporting
// cannot itself be durably published.
}
}
phase(
runCatching {
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
}.getOrNull(),
"error",
result = error.javaClass.simpleName,
)
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
} finally {
conversationLock.unlock()

View file

@ -33,13 +33,16 @@ sealed interface SyncRequestResult {
class SyncRequestOutbox private constructor(
private val publisher: DirectExchangePublisher,
private val visibility: MtpPublicationVisibility,
) {
constructor(context: Context) : this(
DirectExchangePublisher { directExchangeDirectory(context) },
AndroidMtpPublicationVisibility(context),
)
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
DirectExchangePublisher(directoryProvider),
ImmediateMtpPublicationVisibility,
)
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
@ -87,7 +90,12 @@ class SyncRequestOutbox private constructor(
"trainlog-sync-request-v1.json"
val error = snapshot.writeJson(displayName, payload)
return if (error == null) {
SyncRequestResult.Requested(requestId)
try {
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
SyncRequestResult.Requested(requestId)
} catch (error: Exception) {
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
}
} else {
SyncRequestResult.Error(error)
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -378,7 +379,7 @@ private fun BodyMetricField(
)
}
private data class BodyMetricItem(
internal data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
@ -386,10 +387,19 @@ private data class BodyMetricItem(
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
val twoCompactFieldsDp = 2 * 140f + 10f
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
}
@Composable
private fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val columns = if (maxWidth < 330.dp) 1 else 2
/* WHY: this 360 dp device has 312 dp after screen padding; the old
* 330 dp threshold therefore stacked fields that need only 140 dp.
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
* unless width or accessibility font scaling makes them unreadable. */
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {

View file

@ -34,6 +34,8 @@ import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.DriveSyncSettings
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
import com.labfytools.trainlog.data.MtpPublicationVisibility
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -49,6 +51,7 @@ internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
): SyncRequestResult = withContext(Dispatchers.IO) {
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
@ -66,7 +69,7 @@ internal suspend fun publishBundleAndRequest(
is SyncExportResult.Exported -> Unit
}
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository).run(
val result = SyncGenerationForegroundCoordinator(repository, visibility).run(
canonicalExchangeDirectory(),
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
@ -390,10 +393,25 @@ fun SyncScreen(
* published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */
if (backgroundSettings.enabled) {
/* WHY: Samsung may freeze the activity after USB mode
* changes or a permission surface opens. CONTRACT: a
* user-enabled foreground listener is refreshed before
* the UI-owned conversation begins; the process lock
* prevents it from creating a second generation. */
SyncBackgroundService.start(context)
}
syncRunning = true
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
when (
val result = publishBundleAndRequest(
repository,
exporter,
requestOutbox,
AndroidMtpPublicationVisibility(context),
)
) {
is SyncRequestResult.Completed -> {
// The generation coordinator has already consumed the
// desktop return and published its durable ACK. Waiting

View file

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

View file

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

View file

@ -0,0 +1,35 @@
package com.labfytools.trainlog.data
import java.nio.file.Files
import org.json.JSONObject
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 SyncRequestOutboxTest {
@Test
fun distinctExplicitRequestsAlwaysPublishFreshIdentities() {
val directory = Files.createTempDirectory("trainlog-request-outbox-").toFile()
try {
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val first = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val firstBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
val second = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val secondBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
assertNotEquals(first.requestId, second.requestId)
assertNotEquals(firstBytes, secondBytes)
assertTrue(JSONObject(secondBytes).getString("request_id") == second.requestId)
} finally {
directory.deleteRecursively()
}
}
}

View file

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

View file

@ -1067,6 +1067,28 @@ generation remains resumable only until the desktop generation for that run is
durably consumed; a fully completed run is then excluded from foreground
selection. This preserves late-ACK recovery without replaying completed runs.
The direct-MTP path confirms transport visibility independently from ordinary
`File.exists()` checks. Android fsyncs and atomically commits every immutable
generation artifact, asks MediaProvider to scan those files, waits for all
bounded callbacks, and only then atomically publishes and scans
`android-generation-v1.json`. The same boundary covers the peer advertisement,
explicit request signal, Android consumption ACK and bounded error reference.
Manifest/reference remain the commit markers; scanning does not change their
bytes or protocol meaning. Production logging records only `run_id`, phase,
artifact name, generation ID and result.
When background synchronization is enabled, an explicit UI request refreshes
the foreground listener before starting the UI-owned conversation. The shared
process lock prevents a second run while the UI coordinator is alive; if the
activity is frozen or destroyed, the listener can validate and resume the
durable request without inventing another generation.
The Mensurations grid uses two 140 dp compact fields plus a 10 dp gutter at
normal phone widths. The deployed 1080 px/480 dpi phone supplies approximately
312 dp inside the screen frame and therefore receives two columns. Layouts
below 290 dp, or font scales of 1.5 and above, retain the readable one-column
fallback.
Backup manifests retain format version 1 and the current Android schema. Their
`product_version` is read from installed package metadata, so development
builds record `0.1.3` without a second hard-coded product-version source.

View file

@ -9,6 +9,25 @@ USB listener with mandatory notification and a private-folder Drive grant with
bounded WorkManager checks. Both use the same concurrency-guarded generation
coordinator as SyncScreen.
Direct-MTP publication now has an explicit Android visibility boundary.
Durable immutable generation files are submitted to MediaProvider and all scan
callbacks complete before the mutable generation reference is published; the
reference and Android ACK use the same confirmation. Bounded diagnostic logs
contain only run/generation identities, artifact names, phases and results.
The desktop worker performs lightweight local correlation checks continuously
but opens a full MTP pull no more than once every three seconds. A 30-second
individual adapter budget is distinct from the overall conversation deadline,
so an absent correlated generation reports a protocol wait timeout while an
actually blocked adapter reports `transport_timeout`.
The failed private run `sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` received its
desktop request and archive-ACK files on the Android filesystem, but created no
Android generation row/reference/object. System evidence shows the activity
was moved to Android's file-access settings and frozen before capture. The
retained Android generation reference and MTP bytes both belonged to an older
run; no hidden new generation was found. No causal or business mutation
occurred.
Existing preparation rows expose separate editing and Android-delivery states
and an accessible send action. Draft/local advances the same preparation to a
ready revision before delivery; ready/local delivers the current revision.

View file

@ -959,6 +959,19 @@ It reads the peer advertisement, current Android generation reference and
current Android consumption ACK, then downloads only the generation directory
named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history.
Local correlation is checked every 50 ms without USB work; complete MTP pulls
are throttled to a three-second cadence. Each MTP operation has its own
30-second ceiling while the conversation retains its configured global
deadline. Exhausting the global deadline while waiting for a correlated
reference reports `timeout waiting for correlated android-generation-v1.json`;
only an adapter that exceeds its independent operation budget reports
`transport_timeout`.
Android direct-file publication has a separate MediaProvider visibility
barrier. Immutable generation files, including the manifest, become visible
before `android-generation-v1.json` is committed. The reference therefore
cannot advertise a generation whose files have not completed the Android MTP
visibility callbacks. No remote object is removed during polling or scanning.
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
change the frozen Dashboard `next_session.available` field or any exchange

View file

@ -1544,6 +1544,26 @@ fixture's sample draft.
## Private full-generation rollout validation
The MTP visibility regression suite uses injected clocks and transport
callbacks, with no multi-second sleeps. It covers delayed Android publication,
multiple throttled polls, stale run references, a never-visible generation,
an independently blocked adapter, and exact protocol-versus-transport timeout
classification. Android tests prove fresh request UUIDs, foreground/background
lock ownership, objects-visible-before-reference ordering, bounded phase
tracing, and a completed full-generation conversation. Compose tests exercise
the measured 312 dp phone content width as two columns, a true 280 dp narrow
layout as one column, 1.5 font-scale fallback, long French labels and `kg`/`cm`
suffixes.
Read-only forensics for failed run
`sy_9639f06e-58ba-4bc0-ab1b-1488aefe011c` found the correlated desktop request
and archive acknowledgements on Android, but no correlated generation
reference, immutable directory or error record. The direct filesystem and MTP
views contained identical bytes for the retained older Android reference.
Logcat showed Android moving Trainlog behind the file-access settings surface
and freezing it before capture. The post-v19 backup predates this run and is
not presented as SQLite evidence for it.
The authorized 2026-09-17 rollout added regression coverage for production
failures found only after installation: feedback import under `sqlite3.Row`,
the complete exporter inventory of the relocatable package, stale Android ACK

View file

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

View file

@ -79,7 +79,9 @@ class SyncdRoutingTest(unittest.TestCase):
production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(
"internal suspend fun publishLegacyBundleAndRequest", 1
)[0]
self.assertIn("SyncGenerationForegroundCoordinator(repository).run", production)
self.assertIn(
"SyncGenerationForegroundCoordinator(repository, visibility).run", production
)
self.assertNotIn("generation-opt-in", production)
self.assertIn("exportMobileBundle", production)
self.assertIn("generation below", production)

View file

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

View file

@ -876,6 +876,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_orchestrator.py'],
)
test(
'sync_peer_worker',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_peer_worker.py'],
)
test(
'sync_drive_transport',
find_program('python3'),