feat(sync): add USB and Drive Android synchronization

This commit is contained in:
fy59 2026-09-20 08:03:25 +02:00
parent 3ad50be247
commit 799054ee2c
34 changed files with 1975 additions and 77 deletions

View file

@ -2,6 +2,26 @@
## Unreleased — 0.1.2
- Added USB-priority full-generation synchronization with a separately
configured private Drive mirror/fallback transport, verified manifest-last
publication, shared multi-transport identities/ACKs, and explicit per-
transport Web status without ever transporting SQLite.
- Added an Android user-enabled foreground USB listener, private Drive folder
connection with bounded WorkManager checks, and one shared concurrency-
guarded generation coordinator for UI and background triggers.
- Added an accessible preparation-list send action with separate editing and
delivery states, durable retry behavior, duplicate-delivery prevention and
stable execution-identity transition from Preparation to History/Program
completion.
- Implemented `TRAINLOG_PROGRAM_CALENDAR_V1` as the operational top-level
`/programmes` daily active-Program calendar. It consumes typed Core reads and
preparation creation, derives Monday–Sunday weeks from Program bounds and
real planned dates without inventing rest days, keeps undated sessions
separate, and rereads Core state after Prepare. Sessions → Programmes remains
the administration/import/list/detail/archive/delete surface; this adds no
Core, API, schema, format, synchronization, or Android changes.
- Fixed Program-to-preparation creation for Program V1 target weights written
as integer JSON numbers. Imports now preserve both integer and real number
spellings instead of persisting integer weights as zero. Existing affected

View file

@ -1,5 +1,12 @@
# Trainlog
Synchronization uses one generation/ACK engine over USB/MTP first and an
optional private Google Drive mirror/fallback. Drive contains only versioned
Trainlog artifacts under its separate Sync namespace; desktop and Android
SQLite databases always remain local. Android can process PC USB requests from
an explicitly enabled, visibly notified background service, while Drive checks
follow Android's bounded background scheduling.
Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and

View file

@ -146,6 +146,9 @@ dependencies {
implementation(
"androidx.activity:activity-compose:1.13.0"
)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.10.5")
implementation(
"androidx.compose.foundation:foundation"

View file

@ -5,6 +5,9 @@
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
user-visible directory, Documents/Trainlog, without a SAF provider. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:allowBackup="false"
@ -26,5 +29,10 @@
android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

@ -0,0 +1,338 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import org.json.JSONObject
internal class DriveSyncSettings(private val context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val treeUri: Uri? get() = preferences.getString(TREE, null)?.let(Uri::parse)
val connected: Boolean get() = treeUri != null
fun connect(uri: Uri) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences.edit().putString(TREE, uri.toString()).apply()
schedule(context)
}
fun disconnect() {
treeUri?.let { uri ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
preferences.edit().remove(TREE).apply()
WorkManager.getInstance(context).cancelUniqueWork(WORK)
}
companion object {
private const val PREFERENCES = "trainlog-drive-sync"
private const val TREE = "private_tree_uri"
private const val WORK = "trainlog-drive-sync-v1"
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<DriveSyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<DriveSyncWorker>().build())
}
}
}
/** Byte-only adapter over one user-selected private Drive folder. */
internal class DriveDocumentTransport(private val context: Context, treeUri: Uri) {
private val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalStateException("Drive folder is unavailable")
private val resolver = context.contentResolver
private val directories = mutableMapOf("" to root)
private fun segments(relative: String): List<String> {
val parts = relative.split('/')
require(parts.size in 1..4 && parts.all { it.isNotEmpty() && it != "." && it != ".." && '\\' !in it })
return parts
}
private fun find(relative: String): DocumentFile? {
val parts = segments(relative)
var parent = root
var prefix = ""
parts.forEachIndexed { index, name ->
if (index == parts.lastIndex) return parent.findFile(name)
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
parent =
directories[prefix]
?: parent.findFile(name)?.also {
if (it.isDirectory) directories[prefix] = it
}
?: return null
}
return null
}
private fun ensureParent(parts: List<String>): DocumentFile {
var parent = root
var prefix = ""
for (name in parts) {
prefix = if (prefix.isEmpty()) name else "$prefix/$name"
parent =
directories[prefix]
?: parent.findFile(name)
?: parent.createDirectory(name)
?: throw IllegalStateException("Cannot create Drive folder")
require(parent.isDirectory)
directories[prefix] = parent
}
return parent
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { stream ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = stream.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun downloadDocument(source: DocumentFile, destination: File): Boolean {
if (!source.isFile || source.length() > 64L * 1024L * 1024L) return false
destination.parentFile?.mkdirs()
val temporary = File(destination.parentFile, ".${destination.name}.drive.tmp-${UUID.randomUUID()}")
resolver.openInputStream(source.uri)?.use { input ->
FileOutputStream(temporary).use { output -> input.copyTo(output); output.fd.sync() }
} ?: return false
/* WHY: coordination files are deliberately replaced across runs, and
* File.renameTo() does not reliably replace an existing destination.
* CONTRACT: consumers observe the old complete file or the newly
* downloaded complete file, never a partial provider stream.
* INVARIANT: the temporary is unique and on the destination filesystem. */
try {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
} catch (error: Exception) {
temporary.delete()
throw error
}
return true
}
private fun download(relative: String, destination: File): Boolean =
find(relative)?.let { downloadDocument(it, destination) } ?: false
private fun upload(relative: String, source: File) {
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath()))
val parts = segments(relative)
val parent = ensureParent(parts.dropLast(1))
val destination = parent.findFile(parts.last())
?: parent.createFile("application/json", parts.last())
?: throw IllegalStateException("Cannot create Drive object")
resolver.openOutputStream(destination.uri, "wt")?.use { output ->
source.inputStream().use { it.copyTo(output) }
output.flush()
} ?: throw IllegalStateException("Cannot write Drive object")
val verification = File.createTempFile("trainlog-drive-verify-", ".json", context.cacheDir)
try {
/* WHY: some SAF providers do not make a newly created child
* discoverable by name immediately. CONTRACT: verification reads
* the exact object returned by createFile/findFile above.
* INVARIANT: size and SHA-256 are checked before publication can
* advance to a manifest or coordination reference. */
require(downloadDocument(destination, verification))
require(verification.length() == source.length() && sha256(verification) == sha256(source))
} finally {
verification.delete()
}
}
fun pull(localRoot: File) {
localRoot.mkdirs()
val coordination = listOf(
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
"android-generation-error-v1.json", "request-v1.json",
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
coordination.forEach { download(it, File(localRoot, it)) }
listOf("android-generation-v1.json", "desktop-generation-v1.json").forEach { name ->
val reference = File(localRoot, name)
if (!reference.isFile || reference.length() > 64 * 1024) return@forEach
val value = JSONObject(reference.readText())
val relative = value.getString("relative_path")
val generationParts = relative.split('/')
require(
generationParts.size == 3 &&
generationParts[0] in setOf("android-objects", "desktop-objects") &&
generationParts[1] == "generations" &&
generationParts[2] == value.getString("generation_id")
)
val manifestPath = "$relative/manifest.json"
val manifest = File(localRoot, manifestPath)
if (!download(manifestPath, manifest)) return@forEach
val artifacts = JSONObject(manifest.readText()).getJSONArray("artifacts")
require(artifacts.length() <= 32)
val generationRelative = "${generationParts[1]}/${generationParts[2]}"
for (index in 0 until artifacts.length()) {
val filename = artifacts.getJSONObject(index).getString("filename")
require(filename.substringBeforeLast('/', "") == generationRelative)
val artifactPath = "${generationParts[0]}/$filename"
require(download(artifactPath, File(localRoot, artifactPath)))
}
}
}
fun pushPeer(localRoot: File) {
upload("android-peer-v1.json", File(localRoot, "android-peer-v1.json"))
}
fun pushGeneration(localRoot: File) {
val reference = File(localRoot, "android-generation-v1.json")
require(reference.isFile && reference.length() <= 64 * 1024)
val value = JSONObject(reference.readText())
val relative = value.getString("relative_path")
val parts = relative.split('/')
require(
parts.size == 3 &&
parts[0] == "android-objects" &&
parts[1] == "generations" &&
parts[2] == value.getString("generation_id")
)
val generation = File(localRoot, relative)
require(generation.isDirectory && !java.nio.file.Files.isSymbolicLink(generation.toPath()))
val files =
generation.listFiles()?.filter { it.isFile }?.sortedWith(
compareBy<File> { it.name == "manifest.json" }
.thenBy { it.name },
) ?: throw IllegalStateException("Drive generation is unavailable")
require(files.lastOrNull()?.name == "manifest.json")
/* WHY: a Drive tree may contain downloaded desktop objects, stale
* generations, and local staging. Uploading that cache made a single
* phase exceed WorkManager's execution window and crossed transport
* ownership boundaries. CONTRACT: Android publishes only the current
* immutable Android generation. INVARIANT: artifacts precede the
* manifest commit marker, and the correlated reference is last. */
files.forEach { upload("$relative/${it.name}", it) }
upload("android-generation-v1.json", reference)
}
fun pushAcknowledgement(localRoot: File) {
upload(
"android-consumption-ack-v1.json",
File(localRoot, "android-consumption-ack-v1.json"),
)
}
}
class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) {
companion object {
/* WHY: reconnecting Drive updates periodic work and also enqueues an
* immediate pass; Android may start both before either reaches the
* Core conversation lock. CONTRACT: only one Drive byte conversation
* runs in this process. INVARIANT: a duplicate trigger invents no
* generation and the active worker remains the durable authority. */
private val running = AtomicBoolean(false)
}
override suspend fun doWork(): Result {
val uri = DriveSyncSettings(applicationContext).treeUri ?: return Result.success()
if (!running.compareAndSet(false, true)) return Result.success()
val local = File(applicationContext.filesDir, "sync-drive-v1")
val transport = DriveDocumentTransport(applicationContext, uri)
val repository = TrainlogRepository(applicationContext)
var stage = "pull"
return try {
transport.pull(local)
stage = "coordinate"
val coordinator = SyncGenerationCoordinator(repository)
coordinator.publishPeer(local)
stage = "peer-publication"
transport.pushPeer(local)
if (!coordinator.hasActionableRequest(local)) {
if (coordinator.hasReplayableAcknowledgement(local)) {
stage = "acknowledgement-replay"
transport.pushAcknowledgement(local)
}
return Result.success()
}
var lastPull = System.nanoTime()
val boundedPull = {
val now = System.nanoTime()
if (now - lastPull >= Duration.ofSeconds(5).toNanos()) {
transport.pull(local)
lastPull = System.nanoTime()
}
}
val result = coordinator.run(
local,
Duration.ofMinutes(10),
afterPeerPublication = { transport.pushPeer(local) },
afterPublication = {
stage = "generation-publication"
transport.pushGeneration(local)
},
pollTransport = boundedPull,
afterAcknowledgement = {
stage = "acknowledgement"
transport.pushAcknowledgement(local)
},
)
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
} catch (error: Exception) {
/* SECURITY: stage and exception class are fixed application
* metadata. Provider messages may contain private URIs and are
* deliberately never written to logs. */
Log.w("TrainlogDriveSync", "Drive sync failed at $stage (${error.javaClass.simpleName})")
Result.retry()
} finally {
repository.close()
running.set(false)
}
}
}

View file

@ -0,0 +1,144 @@
package com.labfytools.trainlog.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
import java.time.Duration
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
internal class BackgroundSyncSettings(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
var enabled: Boolean
get() = preferences.getBoolean(ENABLED, false)
private set(value) { preferences.edit().putBoolean(ENABLED, value).apply() }
fun enable() { enabled = true }
fun disable() { enabled = false }
companion object {
private const val PREFERENCES = "trainlog-background-sync"
private const val ENABLED = "enabled"
}
}
/**
* User-enabled foreground listener for PC-initiated USB generation requests.
*
* WHY: a Web request must be consumed while Compose is not visible, but modern
* Android does not permit an invisible permanent background loop.
* CONTRACT: the service shows a mandatory notification, invokes the same
* generation coordinator as SyncScreen, and never starts an exercise.
* INVARIANT: filesystem visibility only triggers protocol validation; stable
* run/generation/ACK identities remain the sole business authority.
*/
class SyncBackgroundService : Service() {
private val stopped = AtomicBoolean(false)
private val launched = AtomicBoolean(false)
private val executor = Executors.newSingleThreadExecutor()
private var repository: TrainlogRepository? = null
override fun onCreate() {
super.onCreate()
createChannel()
val notification = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
repository = TrainlogRepository(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP || !BackgroundSyncSettings(this).enabled) {
stopSelf()
return START_NOT_STICKY
}
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation
* lock and would starve Drive while USB is idle. CONTRACT: a
* filesystem observation only schedules bounded validation;
* the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory)
if (coordinator.hasActionableRequest(directory)) {
coordinator.run(directory, Duration.ofMinutes(5))
}
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}
stopSelf()
}
return START_NOT_STICKY
}
override fun onDestroy() {
stopped.set(true)
executor.shutdownNow()
repository?.close()
repository = null
super.onDestroy()
}
override fun onTimeout(startId: Int, fgsType: Int) {
/* Android 15 bounds dataSync foreground time across the application.
* Stop immediately rather than risking an ANR; durable generations
* and ACKs make a later explicit restart safe. */
stopped.set(true)
stopSelf(startId)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.background_sync_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
companion object {
private const val CHANNEL = "trainlog_sync"
private const val NOTIFICATION_ID = 1401
private const val ACTION_START = "com.labfytools.trainlog.sync.START"
private const val ACTION_STOP = "com.labfytools.trainlog.sync.STOP"
fun start(context: Context) {
BackgroundSyncSettings(context).enable()
ContextCompat.startForegroundService(
context,
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
BackgroundSyncSettings(context).disable()
context.startService(
Intent(context, SyncBackgroundService::class.java).setAction(ACTION_STOP),
)
}
}
}

View file

@ -2,6 +2,9 @@ package com.labfytools.trainlog.data
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Duration
import java.util.UUID
import org.json.JSONArray
@ -14,9 +17,17 @@ internal sealed interface ForegroundGenerationResult {
}
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository)
companion object {
/* WHY: UI and background triggers share one repository protocol and
* must never create concurrent runs. CONTRACT: this process-wide lock
* guards only one bounded conversation; SQLite remains the durable
* replay authority after process restart. */
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
}
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
@ -45,6 +56,59 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
}
}
internal fun hasActionableRequest(directory: File): Boolean {
val path = File(directory, "request-v1.json")
if (!path.isFile || path.length() !in 1..64 * 1024) return false
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
val runId = request.optString("run_id")
val desktopPeerId = request.optString("desktop_peer_id")
if (
request.optString("format") != "trainlog-sync-generation-request" ||
request.optInt("version", -1) != 1 ||
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
request.optString("android_peer_id") != service.peerId()
) return false
if (resumableGeneration(runId, desktopPeerId) != null) return true
return !repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
}
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
val request =
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
?: return false
val reference =
runCatching { JSONObject(File(directory, "desktop-generation-v1.json").readText()) }
.getOrNull() ?: return false
val acknowledgement =
runCatching { JSONObject(File(directory, "android-consumption-ack-v1.json").readText()) }
.getOrNull() ?: return false
/* WHY: a network failure may occur after Core commits consumption but
* before Drive receives its ACK. CONTRACT: only the exact durable ACK
* correlated to the current request and desktop reference is replayed.
* INVARIANT: replay transports existing evidence and never recaptures
* a generation or repeats a domain mutation. */
return request.optString("format") == "trainlog-sync-generation-request" &&
request.optInt("version", -1) == 1 &&
request.optString("android_peer_id") == service.peerId() &&
reference.optString("format") == "trainlog-sync-generation-reference" &&
reference.optInt("version", -1) == 1 &&
acknowledgement.optString("format") == "trainlog-sync-ack" &&
acknowledgement.optInt("version", -1) == 1 &&
acknowledgement.optString("run_id") == request.optString("run_id") &&
reference.optString("run_id") == request.optString("run_id") &&
acknowledgement.optString("generation_id") == reference.optString("generation_id") &&
acknowledgement.optString("manifest_sha256") == reference.optString("manifest_sha256") &&
acknowledgement.optString("producer_peer_id") == request.optString("desktop_peer_id") &&
acknowledgement.optString("consumer_peer_id") == service.peerId() &&
acknowledgement.optString("result") in setOf("consumed", "rejected")
}
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields")
@ -104,13 +168,78 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
stream.flush()
stream.fd.sync()
}
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
/* WHY: Drive conversations reuse coordination filenames, and
* File.renameTo() does not reliably replace an existing destination
* on Android private storage. CONTRACT: readers observe either the
* previous complete JSON document or its complete replacement.
* INVARIANT: the temporary file is in the destination directory, so
* the preferred move is same-filesystem and atomic when supported. */
try {
Files.move(
temporary.toPath(),
path.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
path.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: Exception) {
temporary.delete()
throw SyncGenerationException("cannot publish ${path.name}")
}
}
private fun await(path: File, deadline: Long): File {
/* WHY: transports must advertise a stable installation before a desktop
* can address its first request. CONTRACT: discovery publishes no domain
* facts and does not acquire the conversation lock. INVARIANT: run() uses
* this exact descriptor, so idle discovery cannot fork capabilities. */
internal fun publishPeer(directory: File): String {
if (!directory.isDirectory)
throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
val path = File(directory, "android-peer-v1.json")
val descriptor =
JSONObject()
.put("format", "trainlog-sync-peer")
.put("version", 1)
.put("peer_id", peer)
.put(
"capabilities",
JSONArray(
listOf(
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
"session-preparations-v2",
"programs-v1",
)
),
)
.toString()
/* WHY: the foreground listener probes every five seconds, while some
* Android MediaProvider implementations cannot atomically replace an
* unchanged file that is simultaneously exposed over MTP. CONTRACT:
* 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))
return peer
publish(path, descriptor)
return peer
}
private fun await(path: File, deadline: Long, pump: (() -> Unit)? = null): File {
while (!path.isFile) {
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for ${path.name}")
pump?.invoke()
Thread.sleep(50)
}
return path
@ -120,6 +249,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
path: File,
deadline: Long,
predicate: (JSONObject) -> Boolean,
pump: (() -> Unit)? = null,
): ByteArray {
while (true) {
if (path.isFile && path.length() <= 64 * 1024) {
@ -133,6 +263,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
}
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for correlated ${path.name}")
pump?.invoke()
Thread.sleep(50)
}
}
@ -142,34 +273,14 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult =
try {
if (!directory.isDirectory)
throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
publish(
File(directory, "android-peer-v1.json"),
JSONObject()
.put("format", "trainlog-sync-peer")
.put("version", 1)
.put("peer_id", peer)
.put(
"capabilities",
JSONArray(
listOf(
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
"session-preparations-v2",
"programs-v1",
)
),
)
.toString(),
)
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
): ForegroundGenerationResult {
if (!conversationLock.tryLock()) {
return ForegroundGenerationResult.Error("Synchronization already in progress.")
}
return try {
val peer = publishPeer(directory)
/* 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
@ -179,7 +290,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
afterPeerPublication?.invoke()
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
(resumableGeneration(runId) != null ||
@ -189,7 +300,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
arrayOf(runId),
).use { it.moveToFirst() }
})
}
}, pollTransport)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
@ -197,10 +308,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
awaitCorrelated(
File(directory, "desktop-archive-acknowledgements-v1.json"),
deadline,
) {
it.optString("run_id") == request.getString("run_id") &&
it.optString("android_peer_id") == peer
}
{
it.optString("run_id") == request.getString("run_id") &&
it.optString("android_peer_id") == peer
},
pollTransport,
)
reconcileArchiveAcknowledgements(
archiveAckRaw,
request.getString("run_id"),
@ -240,19 +353,28 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
val desktopAck =
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("generation_id") == captured.generationId
}
}, pollTransport)
service.acceptAcknowledgement(desktopAck)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline) {
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId
}
}, pollTransport)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
/* WHY: non-atomic transports can expose a newly downloaded
* reference locally before the corresponding immutable objects
* have reached this process. CONTRACT: correlation validates the
* reference first, then one transport refresh stages its committed
* generation before Core validates hashes and imports it.
* INVARIANT: the refresh moves bytes only; service.consume remains
* the sole validation and mutation authority. */
pollTransport?.invoke()
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
afterAcknowledgement?.invoke()
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
if (error.message == "generation retention capacity exhausted") {
@ -275,5 +397,12 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
}
}
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
} finally {
conversationLock.unlock()
}
}
}
/* Compatibility name for focused tests and callers outside the coordinator
* migration. New triggers should depend on SyncGenerationCoordinator. */
internal typealias SyncGenerationForegroundCoordinator = SyncGenerationCoordinator

View file

@ -8,6 +8,8 @@ package com.labfytools.trainlog.ui
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
import android.content.Context
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
@ -28,6 +30,9 @@ import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.DriveSyncSettings
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
@ -164,6 +169,34 @@ fun SyncScreen(
}
var syncRunning by remember { mutableStateOf(false) }
val backgroundSettings = remember { BackgroundSyncSettings(context) }
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
val driveSettings = remember { DriveSyncSettings(context) }
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
val driveFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
runCatching { driveSettings.connect(uri) }
.onSuccess {
driveConnected = true
status = strings.getString(R.string.drive_sync_connected)
}
.onFailure { status = strings.getString(R.string.drive_sync_connection_failed) }
}
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
SyncBackgroundService.start(context)
backgroundEnabled = true
} else {
status = strings.getString(R.string.background_sync_notification_required)
}
}
LaunchedEffect(
pendingRequestId
@ -453,6 +486,58 @@ fun SyncScreen(
)
}
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.background_sync_description),
color = if (backgroundEnabled) colors.success else colors.warning,
)
TrainlogAction(
label = strings.getString(
if (backgroundEnabled) R.string.background_sync_disable
else R.string.background_sync_enable,
),
description = strings.getString(R.string.background_sync_description),
accent = if (backgroundEnabled) colors.warning else colors.success,
onClick = {
if (backgroundEnabled) {
SyncBackgroundService.stop(context)
backgroundEnabled = false
} else if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
SyncBackgroundService.start(context)
backgroundEnabled = true
}
},
)
}
TrainlogFrame(title = strings.getString(R.string.drive_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.drive_sync_description),
color = if (driveConnected) colors.success else colors.warning,
)
TrainlogAction(
label = strings.getString(
if (driveConnected) R.string.drive_sync_disconnect else R.string.drive_sync_connect,
),
description = strings.getString(R.string.drive_sync_latency),
accent = if (driveConnected) colors.warning else colors.success,
onClick = {
if (driveConnected) {
driveSettings.disconnect()
driveConnected = false
} else {
driveFolderLauncher.launch(null)
}
},
)
}
if (status != null) {
TrainlogFrame(
title = strings.getString(R.string.sync_status),

View file

@ -95,6 +95,21 @@
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</string>
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
<string name="background_sync_enable">Enable background synchronization</string>
<string name="background_sync_disable">Disable background synchronization</string>
<string name="background_sync_channel">Trainlog synchronization</string>
<string name="background_sync_notification_title">Trainlog is ready to synchronize</string>
<string name="background_sync_notification_text">The service is waiting for a PC request.</string>
<string name="background_sync_notification_required">Allow notifications to enable background synchronization.</string>
<string name="drive_sync_title">Private Google Drive</string>
<string name="drive_sync_description">Select the private Trainlog/Sync/v1 folder. Trainlog only accesses that folder and never copies SQLite into it.</string>
<string name="drive_sync_connect">Connect Google Drive</string>
<string name="drive_sync_disconnect">Disconnect Google Drive</string>
<string name="drive_sync_latency">Background checks are bounded by Android and can take at least 15 minutes.</string>
<string name="drive_sync_connected">Drive folder connected. A check has been scheduled.</string>
<string name="drive_sync_connection_failed">Unable to retain access to the Drive folder.</string>
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
<string name="sync_request_sent">Request sent · waiting for the PC…</string>

View file

@ -95,6 +95,21 @@
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</string>
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
<string name="background_sync_enable">Activer la synchronisation en arrière-plan</string>
<string name="background_sync_disable">Désactiver la synchronisation en arrière-plan</string>
<string name="background_sync_channel">Synchronisation Trainlog</string>
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
<string name="drive_sync_title">Google Drive privé</string>
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
<string name="drive_sync_connect">Connecter Google Drive</string>
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>

View file

@ -104,6 +104,7 @@ class SyncDeploymentConversationTest {
val coordinator = SyncGenerationForegroundCoordinator(repository)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var requestSignals = 0
var refreshedAfterDesktopReference = false
val result =
executor.submit<ForegroundGenerationResult> {
coordinator.run(
@ -111,6 +112,14 @@ class SyncDeploymentConversationTest {
Duration.ofSeconds(30),
afterPeerPublication = { requestSignals += 1 },
afterPublication = { release?.let { awaitFile(File(it)) } },
pollTransport = {
refreshedAfterDesktopReference =
runCatching {
JSONObject(File(transport, "desktop-generation-v1.json").readText())
.optString("format") ==
"trainlog-sync-generation-reference"
}.getOrDefault(false)
},
)
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
@ -143,6 +152,10 @@ class SyncDeploymentConversationTest {
completed is ForegroundGenerationResult.Completed,
)
assertEquals("one fresh request signal per conversation", 1, requestSignals)
assertTrue(
"transport is refreshed after the correlated desktop reference",
refreshedAfterDesktopReference,
)
var workerOutput = ""
localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText()

View file

@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
@ -349,6 +350,113 @@ class SyncGenerationServiceTest {
}
}
@Test
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-actionable-").toFile()
val repository = TrainlogRepository(context, databaseName)
try {
val coordinator = SyncGenerationCoordinator(repository)
assertEquals(false, coordinator.hasActionableRequest(root))
File(root, "request-v1.json").writeText("not-json")
assertEquals(false, coordinator.hasActionableRequest(root))
val peer = SyncGenerationService(repository).peerId()
val request =
JSONObject()
.put("format", "trainlog-sync-generation-request")
.put("version", 1)
.put("run_id", "sy_${UUID.randomUUID()}")
.put("android_peer_id", "peer_${UUID.randomUUID()}")
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
File(root, "request-v1.json").writeText(request.toString())
assertEquals(false, coordinator.hasActionableRequest(root))
request.put("android_peer_id", peer)
File(root, "request-v1.json").writeText(request.toString())
assertEquals(true, coordinator.hasActionableRequest(root))
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun repeatedPeerPublicationLeavesIdenticalCanonicalFileUntouched() {
val databaseName = "generation-peer-publication-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-peer-publication-").toFile()
val repository = TrainlogRepository(context, databaseName)
try {
val coordinator = SyncGenerationCoordinator(repository)
val peer = coordinator.publishPeer(root)
val descriptor = File(root, "android-peer-v1.json")
val retainedTimestamp = 1_234_000L
assertTrue(descriptor.setLastModified(retainedTimestamp))
assertEquals(peer, coordinator.publishPeer(root))
assertEquals(retainedTimestamp, descriptor.lastModified())
assertEquals(peer, JSONObject(descriptor.readText()).getString("peer_id"))
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun coordinatorReplaysOnlyStrictlyCorrelatedDurableAcknowledgement() {
val databaseName = "generation-ack-replay-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-ack-replay-").toFile()
val repository = TrainlogRepository(context, databaseName)
try {
val coordinator = SyncGenerationCoordinator(repository)
val androidPeer = SyncGenerationService(repository).peerId()
val desktopPeer = "peer_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
val generationId = "gen_${UUID.randomUUID()}"
val digest = "a".repeat(64)
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()
)
File(root, "desktop-generation-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
.put("run_id", runId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
.toString()
)
val acknowledgement =
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("run_id", runId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
.put("producer_peer_id", desktopPeer)
.put("consumer_peer_id", androidPeer)
.put("result", "consumed")
File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString())
assertEquals(true, coordinator.hasReplayableAcknowledgement(root))
acknowledgement.put("generation_id", "gen_${UUID.randomUUID()}")
File(root, "android-consumption-ack-v1.json").writeText(acknowledgement.toString())
assertEquals(false, coordinator.hasReplayableAcknowledgement(root))
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name = "generation-migration-${UUID.randomUUID()}.db"

View file

@ -1,5 +1,24 @@
# Android application
## Background USB and private Drive synchronization
SyncScreen and the explicitly enabled background service invoke one
`SyncGenerationCoordinator` above `SyncGenerationService`. The shared guard
permits one device conversation at a time. The USB listener is a real
`dataSync` foreground service with a dedicated visible notification; disabling
the setting stops it. It validates a request and generation before any domain
mutation and never starts a prepared workout.
Private Drive access uses a user-selected system document-provider folder and
persisted folder-only permission. Disconnect revokes that permission and
cancels periodic work. WorkManager performs a connected-network check at
connection and periodic checks thereafter; Android's minimum periodic interval
is 15 minutes and OEM scheduling can add latency. Drive therefore provides
durable fallback, not an undocumented instant push promise. Android 15+ does
not permit a `dataSync` foreground service to be launched from
`BOOT_COMPLETED`, so the USB listener requires user activation again after
reboot. Local data remains usable offline and no SQLite file enters Drive.
Android schema v22 adds a pending manual-preparation store which is separate
from AI proposals and the exactly-one active-session singleton. A received
preparation is inert until explicit start; an occupied singleton is never

View file

@ -1,5 +1,22 @@
# Architecture
## Synchronization transport boundary
Generation capture, validation, causal import, publication and ACK acceptance
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
probes the expected MTP peer before preferring USB. A successful USB run is
committed independently, then mirrored to Drive best-effort; Drive failure can
never reopen its SQLite transactions. If MTP is unavailable, the same worker
runs over the Drive byte adapter.
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
SyncScreen, the visible USB foreground service, and the private-folder Drive
worker. One process guard prevents overlap, while generation and ACK tables
remain the restart authority. Transport staging and network/provider waits are
outside SQLite transactions. Neither transport can select or traverse an
application database.
Full-generation synchronization uses the common admission lock and a fixed
coordinator worker. Browser input cannot select commands, paths, timeouts or
capabilities. Peer identity/capabilities come from Android advertisement;

View file

@ -1,5 +1,20 @@
# Current implementation state
Full-generation Web synchronization now selects an actually usable MTP peer
before USB, falls back to a separately configured private Drive generation
namespace, and mirrors successful USB conversations to Drive on a best-effort
basis without rolling back USB. Both transports move the same generation/ACK
bytes; neither transports SQLite. Android has an explicitly enabled foreground
USB listener with mandatory notification and a private-folder Drive grant with
bounded WorkManager checks. Both use the same concurrency-guarded generation
coordinator as SyncScreen.
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.
Completed history hides the active preparation exclusively through the
delivery's stable execution-session identity and retains all provenance.
The opt-in full-generation path is operational on the private daily
installation. The loopback Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and

View file

@ -0,0 +1,53 @@
# TRAINLOG_SYNC_USB_DRIVE_ANDROID_V1 manual validation
Automated tests use isolated directory and fake Drive transports. The first
live validation must use a disposable namespace only:
```text
Trainlog/Sync-Test/<run-id>/
```
It must never target `Trainlog/Sync`, `Trainlog/AI`, `Documents/Trainlog` from a
real user installation, or either SQLite database.
## Preconditions
1. Create a fresh UUIDv4-based `<run-id>` and an empty private Drive folder.
2. Configure a disposable rclone remote/path for that exact folder.
3. In a debug/test Android installation, select that same folder through the
system document picker.
4. Use disposable desktop and Android databases containing one synthetic
preparation and no real account data.
5. Record hashes and stable IDs before transport; never record credentials,
URI grants, tokens or authorization headers.
## Procedure
1. Desktop publishes artifacts, verifies the uploaded bytes, then publishes
`manifest.json` and the generation reference last.
2. Android consumes the generation, confirms the preparation exactly once and
publishes its correlated ACK plus one synthetic completed-session
generation.
3. Desktop consumes that generation, confirms one History row, one completed
Program execution and one acknowledged preparation delivery, then publishes
its ACK.
4. Replay the same Drive objects and confirm every count is unchanged.
5. Repeat the preserved facts over fake/isolated USB and confirm no duplicate
delivery, execution session, History row or ACK.
6. Interrupt one upload before the manifest and confirm neither peer mutates.
7. Replace one artifact with a same-size SHA-mismatching payload and confirm
rejection before mutation.
8. Stop network access after USB commit and confirm USB remains committed while
Web reports the Drive mirror failure.
9. With SyncScreen closed and the user-enabled USB foreground notification
visible, start a Web run and confirm the full request/generation/import/ACK
conversation without touching Android.
## Cleanup
Remove only `Trainlog/Sync-Test/<run-id>/` and the two disposable databases.
Revoke the Android folder grant and remove the disposable rclone configuration.
Retain the redacted run report, generation/ACK identities and SHA-256 evidence.
Live status for this implementation checkout: `NOT_RUN`; no private Drive,
physical phone or real user data was accessed by automated validation.

View file

@ -3,7 +3,10 @@
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an
explicit per-installation opt-in; Drive is still unconfigured and unvalidated.
explicit per-installation opt-in. The additive private Drive transport and
Android background software are implemented and fake-transport validated;
private live Drive and post-reboot/manual device validation remain explicit
deployment gates.
This document owns future work. The implemented baseline is summarized in
[current_state.md](current_state.md); completed narratives and evidence belong

View file

@ -1,5 +1,41 @@
# Synchronization exchange
## USB-priority and private Drive transport
Full-generation synchronization has one business engine and two byte
transports. Automatic desktop admission acquires the existing global lock,
probes the configured Android MTP peer, selects USB when that peer is actually
usable, and otherwise selects the configured private Drive namespace. A cable
or retained peer advertisement is not availability evidence.
After a successful USB conversation, desktop attempts to mirror the exact
current coordination objects and referenced immutable generations to the
separate configured `Trainlog/Sync/v1` namespace. Artifacts are uploaded and
read back for size/SHA-256 verification; a generation manifest is uploaded
after its artifacts and its mutable reference last. Mirror failure is reported
but never rolls back the already committed USB import or ACK. With Drive
disabled, historical USB behavior remains independent of rclone.
Drive carries only existing versioned artifacts, manifests, references and
ACKs. Desktop and Android SQLite files are never eligible transport objects.
`Trainlog/Sync` is independent from `Trainlog/AI`; the rclone remote is trusted
installation configuration, not a product constant or an HTTP parameter.
Android uses the system document provider: the user explicitly selects the
private `Trainlog/Sync/v1` folder, Trainlog persists access to that folder only,
and Disconnect revokes it. No rclone password, OAuth token or authorization
header is stored or logged by Trainlog. WorkManager checks Drive immediately
after connection and then under Android scheduling constraints; periodic work
has a 15-minute minimum and is not an instant push channel.
The separately enabled USB background mode uses a visible `dataSync`
foreground-service notification and the same `SyncGenerationCoordinator` as
SyncScreen. A process-wide guard rejects concurrent UI/service conversations.
Android 15+ limits `dataSync` foreground-service time and forbids starting it
from `BOOT_COMPLETED`; after reboot the user must reopen Trainlog and reactivate
the listener. A filesystem/provider event is only a prompt to validate the
full run, generation, peer, hash and ACK envelope and never starts a workout.
## Manual session preparations V2
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android

View file

@ -1,5 +1,14 @@
# Tests and validation
`test_sync_drive_transport` proves Drive artifact-before-manifest and
manifest-before-reference publication, rejects unsafe/AI namespaces, and keeps
a partial generation invisible. Orchestrator coverage proves that Drive mirror
failure cannot undo a completed primary transport. `web_sessions` additionally
proves duplicate-delivery rejection and stable-identity active-list removal
after the reserved execution session appears in completed history. Android's
normal unit suite builds the shared generation coordinator, foreground service
and WorkManager transport rather than a test-only protocol copy.
`web_sessions` exercises production preparation creation, durable request
replay, immutable successor revisions, stale-revision conflict, duplicate
exercise occurrences, list search, persisted detail serialization, revision-

View file

@ -1,5 +1,20 @@
# Web Sessions V1
Preparation list rows expose independent `editing_state` and `delivery_state`.
A local draft action saves one new ready revision on the same preparation and
then creates its delivery; a local ready revision is delivered directly.
Pending, acknowledged and remote-unknown preparations never create a second
delivery. The action is disabled while its request is running and every state
has visible text in addition to color.
Successful delivery admission immediately starts the configured common sync
orchestrator. If transport is unavailable, the durable pending delivery remains
retryable; the preparation is not reverted or recreated. When completed
history contains the delivery's exact `execution_session_id`, the preparation
leaves the active list while all preparation revisions, entries and delivery
provenance remain stored. Program completion continues to derive from the Core
`execution_state`, never from list disappearance.
The Sessions route implements four distinct views:
- Preparation: manual preparations and immutable AI proposals;

View file

@ -0,0 +1,145 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"sync_drive_transport", ROOT / "tools/sync_drive_transport.py"
)
drive = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(drive)
class MemoryDrive:
def __init__(self):
self.objects = {}
self.uploads = []
self.downloads = []
def upload_verified(self, source, relative):
self.uploads.append(relative)
self.objects[relative] = Path(source).read_bytes()
def download(self, relative, destination, optional=False):
self.downloads.append(relative)
if relative not in self.objects:
if optional:
return False
raise drive.DriveTransportError("missing object")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(self.objects[relative])
return True
class DriveTransportTest(unittest.TestCase):
def generation(self, root):
generation = root / "desktop-objects/generations/gen_one"
generation.mkdir(parents=True)
artifact = generation / "history.json"
artifact.write_bytes(b"{}\n")
manifest = {
"generation_id": "gen_one",
"artifacts": [
{
"filename": "generations/gen_one/history.json",
"sha256": drive.digest(artifact),
}
],
}
(generation / "manifest.json").write_text(json.dumps(manifest))
(root / "desktop-generation-v1.json").write_text(
json.dumps(
{
"generation_id": "gen_one",
"relative_path": "desktop-objects/generations/gen_one",
}
)
)
def test_manifest_is_uploaded_after_artifacts_and_reference_is_last(self):
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
self.generation(root)
remote = MemoryDrive()
drive.push(remote, root)
self.assertLess(
remote.uploads.index("desktop-objects/generations/gen_one/history.json"),
remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"),
)
self.assertLess(
remote.uploads.index("desktop-objects/generations/gen_one/manifest.json"),
remote.uploads.index("desktop-generation-v1.json"),
)
def test_generation_reference_accepts_its_producer_namespace(self):
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
self.generation(root)
self.assertEqual(
drive.generation_from_reference(root, "desktop-generation-v1.json"),
"desktop-objects/generations/gen_one",
)
value = json.loads((root / "desktop-generation-v1.json").read_text())
value["relative_path"] = "android-objects/generations/gen_one"
(root / "desktop-generation-v1.json").write_text(json.dumps(value))
with self.assertRaises(drive.DriveTransportError):
drive.generation_from_reference(root, "desktop-generation-v1.json")
def test_partial_generation_never_becomes_visible_locally(self):
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
remote = MemoryDrive()
remote.objects["android-generation-v1.json"] = json.dumps(
{
"generation_id": "gen_one",
"relative_path": "android-objects/generations/gen_one",
}
).encode()
remote.objects["android-objects/generations/gen_one/manifest.json"] = json.dumps(
{
"generation_id": "gen_one",
"artifacts": [
{"filename": "generations/gen_one/missing.json"}
],
}
).encode()
with self.assertRaises(drive.DriveTransportError):
drive.pull(remote, root)
self.assertFalse((root / "android-objects/generations/gen_one").exists())
def test_complete_generation_is_pulled_into_its_producer_namespace(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "source"
source.mkdir()
self.generation(source)
remote = MemoryDrive()
drive.push(remote, source)
destination = Path(raw) / "destination"
drive.pull(remote, destination)
self.assertEqual(
(destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
b"{}\n",
)
remote.downloads.clear()
drive.pull(remote, destination)
self.assertNotIn(
"desktop-objects/generations/gen_one/history.json",
remote.downloads,
)
def test_unsafe_or_ai_namespaces_are_rejected(self):
for remote in ("", "remote:Trainlog/AI", "remote:Trainlog/Sync/v1\nother"):
with self.subTest(remote=remote), self.assertRaises(drive.DriveTransportError):
drive.RcloneDrive("rclone", remote)
for relative in ("../db", "/absolute", "a\\b"):
with self.subTest(relative=relative), self.assertRaises(drive.DriveTransportError):
drive.safe_relative(relative)
if __name__ == "__main__":
unittest.main()

View file

@ -134,6 +134,42 @@ class OrchestratorTest(unittest.TestCase):
os.kill(pid, 0)
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
def test_committed_primary_run_survives_drive_mirror_failure(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_drive_transport.py").write_text(
"import sys\nprint('injected Drive failure',file=sys.stderr)\nraise SystemExit(1)\n"
)
peer, desktop = identity("peer_"), identity("peer_")
inbound, outbound = identity("gen_"), identity("gen_")
report = {
"format": "trainlog-sync-worker-report", "version": 1,
"run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop,
"inbound_generation_id": inbound, "outbound_generation_id": outbound,
"manifest_sha256": "a" * 64, "result": "completed",
"sessions_reconciled": 1, "domains": {"history-v4": True}, "drafts": [],
"ai_midpoint": {"result": "not_configured"},
"ai_post_sync": {"result": "not_configured"},
}
(sandbox / "sync_peer_worker.py").write_text(
"import json,sys\nv=" + repr(report) + "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
)
values = self.fixture(
root,
version=2,
drive_enabled=True,
drive_remote="fake:Trainlog/Sync/v1",
)
result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 0, result.stderr)
final = json.loads(values[1].read_text())
self.assertEqual(final["phase"], "completed")
self.assertEqual(final["drive_state"], "failed")
self.assertIn("injected Drive failure", final["drive_diagnostic"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,35 @@
import importlib.util
import subprocess
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"sync_peer_worker", ROOT / "tools/sync_peer_worker.py"
)
worker = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(worker)
class SyncPeerWorkerTest(unittest.TestCase):
def test_drive_adapter_uses_remaining_conversation_deadline(self):
completed = subprocess.CompletedProcess([], 0, b"", b"")
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
worker.run_drive_adapter(
Path("/fixed/adapter.py"),
"pull",
"remote:Trainlog/Sync-Test/run",
Path("/private/transport"),
time.monotonic() + 180,
)
timeout = run.call_args.kwargs["timeout"]
self.assertGreaterEqual(timeout, 178)
self.assertLessEqual(timeout, 180)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Bounded byte transport for the Trainlog generation namespace on rclone.
Business validation, causality, import and acknowledgement remain owned by the
existing generation services. This adapter only moves immutable bytes and
mutable run-correlated coordination objects.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import tempfile
from pathlib import Path, PurePosixPath
COORDINATION = (
"android-peer-v1.json",
"android-generation-v1.json",
"android-consumption-ack-v1.json",
"android-generation-error-v1.json",
"request-v1.json",
"desktop-archive-acknowledgements-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
MAX_FILE = 64 * 1024 * 1024
MAX_GENERATION = 256 * 1024 * 1024
class DriveTransportError(RuntimeError):
pass
def safe_relative(value: str) -> Path:
candidate = PurePosixPath(value)
if (
not value
or value.startswith("/")
or "\\" in value
or "\x00" in value
or any(part in ("", ".", "..") for part in candidate.parts)
or len(candidate.parts) > 4
):
raise DriveTransportError("unsafe Drive object path")
return Path(*candidate.parts)
def digest(path: Path) -> str:
value = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
value.update(chunk)
return value.hexdigest()
class RcloneDrive:
def __init__(self, executable: str, remote: str):
if not remote or "\n" in remote or "\r" in remote or "Trainlog/AI" in remote:
raise DriveTransportError("invalid or AI Drive namespace")
self.executable = executable
self.remote = remote.rstrip("/")
def target(self, relative: str) -> str:
safe_relative(relative)
return f"{self.remote}/{relative}"
def run(self, *arguments: str, timeout: int = 60) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
[self.executable, *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise DriveTransportError(f"Drive transport unavailable: {error}") from error
def download(self, relative: str, destination: Path, optional: bool = False) -> bool:
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with tempfile.TemporaryDirectory(prefix=".trainlog-drive-pull-", dir=destination.parent) as raw:
temporary = Path(raw) / "object"
result = self.run("copyto", self.target(relative), str(temporary))
if result.returncode != 0:
message = result.stderr.decode("utf-8", "replace")[:1024]
if optional and any(token in message.lower() for token in ("not found", "doesn't exist", "directory not found")):
return False
raise DriveTransportError(message.strip() or "Drive download failed")
if not temporary.is_file() or temporary.stat().st_size > MAX_FILE:
raise DriveTransportError("Drive object exceeds local bound")
os.replace(temporary, destination)
return True
def upload_verified(self, source: Path, relative: str) -> None:
if source.is_symlink() or not source.is_file() or source.stat().st_size > MAX_FILE:
raise DriveTransportError("invalid Drive upload source")
result = self.run("copyto", str(source), self.target(relative))
if result.returncode != 0:
raise DriveTransportError(result.stderr.decode("utf-8", "replace")[:1024].strip())
with tempfile.TemporaryDirectory(prefix="trainlog-drive-verify-") as raw:
verification = Path(raw) / "object"
self.download(relative, verification)
if verification.stat().st_size != source.stat().st_size or digest(verification) != digest(source):
raise DriveTransportError("Drive upload verification failed")
def generation_from_reference(root: Path, name: str) -> str | None:
path = root / name
if not path.is_file() or path.stat().st_size > 65536:
return None
value = json.loads(path.read_text(encoding="utf-8"))
relative = value.get("relative_path")
if not isinstance(relative, str):
raise DriveTransportError("invalid generation reference")
safe = safe_relative(relative)
expected_root = (
"android-objects" if name == "android-generation-v1.json" else "desktop-objects"
)
# CONTRACT: references point only at the immutable generation namespace
# owned by their producer. The generation identity is the third path
# component; the middle component is the literal ``generations``.
if (
len(safe.parts) != 3
or safe.parts[0] != expected_root
or safe.parts[1] != "generations"
or safe.parts[2] != value.get("generation_id")
):
raise DriveTransportError("uncorrelated generation reference")
return relative
def pull_generation(drive: RcloneDrive, root: Path, relative: str) -> None:
generation_path = safe_relative(relative)
producer_root = generation_path.parts[0]
generation_relative = Path(*generation_path.parts[1:])
destination = root / relative
# WHY: polling must remain bounded even when Drive retains many immutable
# generations. CONTRACT: os.replace below makes a generation visible
# locally only after every listed artifact has downloaded. INVARIANT: a
# committed local generation is immutable and is validated by the shared
# consumer before import; an incomplete pre-existing path is never hidden.
if destination.is_dir() and (destination / "manifest.json").is_file():
return
if destination.exists():
raise DriveTransportError("incomplete local Drive generation")
manifest_relative = f"{relative}/manifest.json"
with tempfile.TemporaryDirectory(prefix=".trainlog-drive-generation-", dir=root) as raw:
staging = Path(raw) / relative
drive.download(manifest_relative, staging / "manifest.json")
manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8"))
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list) or len(artifacts) > 32:
raise DriveTransportError("invalid Drive generation manifest")
total = (staging / "manifest.json").stat().st_size
for artifact in artifacts:
filename = artifact.get("filename") if isinstance(artifact, dict) else None
if not isinstance(filename, str):
raise DriveTransportError("artifact outside Drive generation")
artifact_path = safe_relative(filename)
if artifact_path.parent != generation_relative:
raise DriveTransportError("artifact outside Drive generation")
artifact_destination = staging / artifact_path.name
drive.download(f"{producer_root}/{filename}", artifact_destination)
total += artifact_destination.stat().st_size
if total > MAX_GENERATION:
raise DriveTransportError("Drive generation exceeds bound")
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
os.replace(staging, destination)
def pull(drive: RcloneDrive, root: Path) -> None:
root.mkdir(parents=True, exist_ok=True, mode=0o700)
for name in COORDINATION:
drive.download(name, root / name, optional=True)
for name in ("android-generation-v1.json", "desktop-generation-v1.json"):
relative = generation_from_reference(root, name)
if relative is not None:
pull_generation(drive, root, relative)
def ordered_files(root: Path) -> list[Path]:
files = [path for path in root.rglob("*") if path.is_file()]
if any(path.is_symlink() for path in files):
raise DriveTransportError("Drive outbox contains a symbolic link")
# CONTRACT: a generation is consumable only after its manifest exists;
# coordination references are published after the referenced directory.
return sorted(
files,
key=lambda path: (
2 if path.name.endswith("generation-v1.json") else
1 if path.name == "manifest.json" else 0,
path.relative_to(root).as_posix(),
),
)
def push(drive: RcloneDrive, root: Path) -> None:
if not root.is_dir():
raise DriveTransportError("Drive outbox is unavailable")
for source in ordered_files(root):
drive.upload_verified(source, source.relative_to(root).as_posix())
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("operation", choices=("pull", "push"))
parser.add_argument("remote")
parser.add_argument("root", type=Path)
parser.add_argument("--rclone", default=os.environ.get("TRAINLOG_RCLONE", "rclone"))
args = parser.parse_args()
drive = RcloneDrive(args.rclone, args.remote)
if args.operation == "pull":
pull(drive, args.root)
else:
push(drive, args.root)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (DriveTransportError, json.JSONDecodeError, OSError) as error:
print(f"Drive transport failed: {error}", file=os.sys.stderr)
raise SystemExit(1) from None

View file

@ -20,6 +20,7 @@ import sys
import tempfile
import time
import ctypes
import shutil
from datetime import datetime, timezone
from pathlib import Path
@ -182,6 +183,57 @@ def validate_result(value: dict, run_id: str) -> dict:
return value
def drive_adapter_path() -> Path:
return Path(__file__).with_name("sync_drive_transport.py")
def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) -> None:
"""Mirror only current protocol objects; never traverse application data."""
coordination = (
"android-peer-v1.json",
"android-generation-v1.json",
"android-consumption-ack-v1.json",
"android-generation-error-v1.json",
"request-v1.json",
"desktop-archive-acknowledgements-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
with tempfile.TemporaryDirectory(prefix="trainlog-drive-mirror-", dir=transport_root.parent) as raw:
outbox = Path(raw)
for name in coordination:
source = transport_root / name
if source.is_file() and not source.is_symlink():
shutil.copy2(source, outbox / name)
for name in ("android-generation-v1.json", "desktop-generation-v1.json"):
reference = outbox / name
if not reference.is_file():
continue
value = load_json(reference, MAX_REPORT)
relative = value.get("relative_path")
if not isinstance(relative, str):
raise RuntimeError("invalid generation reference during Drive mirror")
source = (transport_root / relative).resolve()
if not source.is_relative_to(transport_root.resolve()) or not source.is_dir():
raise RuntimeError("generation reference escapes transport root")
destination = outbox / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, destination, copy_function=shutil.copy2)
result = subprocess.run(
[sys.executable, str(drive_adapter_path()), "push", remote, str(outbox)],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
result.stderr.decode("utf-8", "replace")[:MAX_DIAGNOSTIC].strip()
or "Drive mirror failed"
)
def read_worker(
child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int
) -> tuple[dict, bytes]:
@ -276,7 +328,7 @@ def run(args: argparse.Namespace) -> int:
config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch")
if set(config) != {
base_keys = {
"format",
"version",
"enabled",
@ -285,16 +337,24 @@ def run(args: argparse.Namespace) -> int:
"transport_root",
"owned_root",
"timeout_seconds",
}:
}
drive_keys = {"drive_enabled", "drive_remote"}
if set(config) not in (base_keys, base_keys | drive_keys):
raise RuntimeError("invalid trusted sync configuration")
if (
config["format"] != "trainlog-sync-orchestrator-config"
or config["version"] != 1
or config["version"] not in (1, 2)
or config["enabled"] is not True
):
raise RuntimeError("full-generation synchronization is disabled")
if config["mode"] not in ("directory", "mtp"):
if config["mode"] not in ("directory", "mtp", "drive", "auto"):
raise RuntimeError("invalid transport mode")
drive_enabled = config.get("drive_enabled", False)
drive_remote = config.get("drive_remote", "")
if type(drive_enabled) is not bool or (drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)):
raise RuntimeError("invalid Drive configuration")
if config["mode"] in ("drive", "auto") and not drive_enabled:
raise RuntimeError("Drive transport is not configured")
for key in ("transport_root", "owned_root"):
if not isinstance(config[key], str) or not Path(config[key]).is_absolute():
raise RuntimeError("trusted paths must be absolute")
@ -333,6 +393,28 @@ def run(args: argparse.Namespace) -> int:
"TRAINLOG_SYNC_DATABASE": args.database,
}
)
selected_mode = config["mode"]
adapter = Path(
os.environ.get(
"TRAINLOG_SYNC_MTP_ADAPTER",
str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"),
)
)
if selected_mode == "auto":
# WHY: cable presence is not peer availability. A successful pull
# is the bounded proof that the configured Trainlog peer is usable.
try:
probe = subprocess.run(
[str(adapter), "pull", config["expected_peer_id"], config["transport_root"]],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=min(timeout, 30),
check=False,
) if adapter.is_absolute() and os.access(adapter, os.X_OK) else None
except (OSError, subprocess.TimeoutExpired):
probe = None
selected_mode = "mtp" if probe is not None and probe.returncode == 0 else "drive"
command = [
sys.executable,
str(Path(__file__).with_name("sync_peer_worker.py")),
@ -349,19 +431,16 @@ def run(args: argparse.Namespace) -> int:
"--timeout",
str(timeout),
"--mode",
config["mode"],
selected_mode,
]
if config["mode"] == "mtp":
adapter = os.environ.get(
"TRAINLOG_SYNC_MTP_ADAPTER",
str(
Path(__file__).resolve().parents[1]
/ "build/tui/trainlog-generation-mtp-adapter"
),
)
if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK):
if selected_mode == "mtp":
if not adapter.is_absolute() or not os.access(adapter, os.X_OK):
raise RuntimeError("fixed MTP adapter executable is unavailable")
command.extend(["--mtp-adapter", adapter])
command.extend(["--mtp-adapter", str(adapter)])
elif selected_mode == "drive":
command.extend(
["--drive-adapter", str(drive_adapter_path()), "--drive-remote", drive_remote]
)
child = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
@ -384,6 +463,20 @@ def run(args: argparse.Namespace) -> int:
raise
finally:
_owned_process_group = None
drive_state = "disabled"
drive_diagnostic = ""
if drive_enabled:
if selected_mode == "drive":
drive_state = "success"
else:
try:
mirror_current_exchange(Path(config["transport_root"]), drive_remote, timeout)
drive_state = "mirrored"
except Exception as error:
# CONTRACT: committed USB synchronization is never rolled
# back because its additive safety mirror is unavailable.
drive_state = "failed"
drive_diagnostic = str(error)[:MAX_DIAGNOSTIC]
update(
state_path,
state,
@ -400,6 +493,10 @@ def run(args: argparse.Namespace) -> int:
manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"],
ai_post_sync=result["ai_post_sync"],
usb_state="success" if selected_mode == "mtp" else "unavailable",
drive_state=drive_state,
drive_diagnostic=drive_diagnostic,
transport=selected_mode,
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 0

View file

@ -192,6 +192,62 @@ def push_desktop_generation(
)
def run_drive_adapter(
adapter: Path,
operation: str,
remote: str,
root: Path,
deadline: float,
) -> None:
"""Run the fixed Drive byte adapter without exposing configuration to HTTP."""
remaining = max(1, int(deadline - time.monotonic()))
try:
result = subprocess.run(
[sys.executable, str(adapter), operation, remote, str(root)],
stdin=subprocess.DEVNULL,
capture_output=True,
# CONTRACT: the trusted configuration already bounds the complete
# conversation to at most 900 seconds. A full verified Drive pull
# may legitimately need more than 60 seconds because every object
# is downloaded and hashed before the generation becomes visible.
timeout=remaining,
check=False,
)
except subprocess.TimeoutExpired as error:
raise RuntimeError("transport_timeout: Drive adapter timed out") from error
if result.returncode != 0:
raise RuntimeError(
result.stderr.decode("utf-8", "replace")[:1024].strip()
or "Drive adapter failed"
)
def push_drive(
adapter: Path,
remote: str,
root: Path,
relative_paths: list[str],
deadline: float,
) -> None:
"""Publish a phase-owned outbox through the shared Drive byte adapter."""
with tempfile.TemporaryDirectory(prefix="trainlog-drive-outbox-", dir=root.parent) as directory:
outbox = Path(directory)
os.chmod(outbox, 0o700)
for relative in relative_paths:
source = root / relative
destination = outbox / relative
if source.is_symlink() or not source.exists() or not source.resolve().is_relative_to(root.resolve()):
raise RuntimeError(f"invalid Drive outbox source: {relative}")
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if source.is_dir():
if any(path.is_symlink() for path in source.rglob("*")):
raise RuntimeError(f"Drive outbox contains a symbolic link: {relative}")
shutil.copytree(source, destination, copy_function=shutil.copy2)
else:
shutil.copy2(source, destination)
run_drive_adapter(adapter, "push", remote, outbox, deadline)
def wait_file(path: Path, deadline: float, pump=None) -> None:
while not path.is_file():
if time.monotonic() >= deadline:
@ -294,8 +350,10 @@ def main() -> int:
parser.add_argument("--run-id", required=True)
parser.add_argument("--expected-peer", required=True)
parser.add_argument("--timeout", required=True, type=int)
parser.add_argument("--mode", choices=("directory", "mtp"), default="directory")
parser.add_argument("--mode", choices=("directory", "mtp", "drive"), default="directory")
parser.add_argument("--mtp-adapter", type=Path)
parser.add_argument("--drive-adapter", type=Path)
parser.add_argument("--drive-remote")
args = parser.parse_args()
deadline = time.monotonic() + args.timeout
pump = None
@ -316,6 +374,27 @@ def main() -> int:
args.transport_root,
deadline,
)
elif args.mode == "drive":
if args.drive_adapter is None or not args.drive_remote:
raise RuntimeError("Drive adapter and remote are required")
last_drive_pull = 0.0
def pump_drive(force: bool = False) -> None:
nonlocal last_drive_pull
now = time.monotonic()
if not force and now - last_drive_pull < 5.0:
return
run_drive_adapter(
args.drive_adapter,
"pull",
args.drive_remote,
args.transport_root,
deadline,
)
last_drive_pull = time.monotonic()
pump = pump_drive
pump_drive(True)
peer = load_peer(args.transport_root, args.expected_peer)
with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db)
@ -351,6 +430,14 @@ def main() -> int:
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
deadline,
)
elif args.mode == "drive":
push_drive(
args.drive_adapter,
args.drive_remote,
args.transport_root,
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
deadline,
)
emit(
args.run_id,
"waiting_android_publication",
@ -378,6 +465,14 @@ def main() -> int:
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
deadline,
)
elif args.mode == "drive":
push_drive(
args.drive_adapter,
args.drive_remote,
args.transport_root,
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
deadline,
)
if ack["result"] != "consumed":
raise RuntimeError("desktop rejected Android generation")
emit(
@ -419,6 +514,26 @@ def main() -> int:
ref["relative_path"],
deadline,
)
elif args.mode == "drive":
push_drive(
args.drive_adapter,
args.drive_remote,
args.transport_root,
[ref["relative_path"]],
deadline,
)
push_drive(
args.drive_adapter,
args.drive_remote,
args.transport_root,
[
"request-v1.json",
"desktop-archive-acknowledgements-v1.json",
"desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
],
deadline,
)
emit(
args.run_id,
"published",

View file

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

View file

@ -19,11 +19,15 @@ typedef struct ListDefinition {
static const ListDefinition LISTS[] = {
{"preparation",
"SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at "
"SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at,"
"p.editing_state,p.delivery_state "
"FROM session_preparations p JOIN session_preparation_revisions r ON "
"r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"WHERE p.withdrawn_at IS NULL AND NOT EXISTS(SELECT 1 FROM "
"session_preparation_deliveries d JOIN sessions completed ON "
"completed.session_id=d.execution_session_id WHERE d.preparation_id=p.preparation_id) "
"AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
@ -31,7 +35,7 @@ static const ListDefinition LISTS[] = {
"COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"},
{"proposal",
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN "
"'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
"'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at,NULL,NULL "
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
"WHERE d.withdrawn_at IS NULL AND (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
@ -42,7 +46,7 @@ static const ListDefinition LISTS[] = {
{"draft",
"SELECT "
"d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
"payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
"payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL "
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "
@ -52,7 +56,7 @@ static const ListDefinition LISTS[] = {
{"history",
"SELECT "
"s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s."
"started_at "
"started_at,NULL,NULL "
"FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id "
"WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE "
@ -158,6 +162,8 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
!add_nullable_text(document, item, "state", statement, 3) ||
!yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) ||
!add_nullable_text(document, item, "sort_timestamp", statement, 5) ||
!add_nullable_text(document, item, "editing_state", statement, 6) ||
!add_nullable_text(document, item, "delivery_state", statement, 7) ||
!yyjson_mut_arr_add_val(items, item)) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
@ -208,6 +214,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"p.editing_state,p.delivery_state,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
@ -257,6 +264,11 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
}
}
if (!proposal) {
if (!add_nullable_text(document, root, "editing_state", statement, 8) ||
!add_nullable_text(document, root, "delivery_state", statement, 9)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static const char *const provenance_keys[] = {"source_proposal_id",
"source_proposal_title",
"withdrawn_at",
@ -264,7 +276,7 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
"withdrawal_acknowledged_at"};
for (column = 0; column < 5; ++column) {
if (!add_nullable_text(
document, root, provenance_keys[column], statement, column + 8)) {
document, root, provenance_keys[column], statement, column + 10)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -1274,6 +1286,9 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
database->connection,
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
"p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' "
"AND p.delivery_state='local' AND NOT EXISTS(SELECT 1 FROM "
"session_preparation_deliveries d WHERE d.preparation_id=p.preparation_id AND "
"d.state IN('pending','acknowledged','remote_unknown')) "
"AND EXISTS(SELECT 1 FROM "
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
-1,

View file

@ -227,8 +227,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
"\"target_sets\":1,\"target_reps\":1,\"target_duration_seconds\":null,"
"\"target_weight_kg\":null,\"notes\":null}]}";
TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
char *created = NULL;
char *delivery = NULL;
char *withdrawal = NULL;
@ -245,6 +244,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
char revision_id[128];
char local_preparation_id[128];
char local_revision_id[128];
char execution_session_id[128];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
@ -289,6 +289,14 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
"request-withdraw-deliver",
&delivery,
&delivery_size) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sessions_deliver_json(database,
preparation_id,
revision_id,
"request-withdraw-deliver-duplicate",
&replayed,
&replayed_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(replayed == NULL && replayed_size == 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(trainlog_web_sessions_withdraw_json(database,
preparation_id,
revision_id,
@ -317,8 +325,7 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") ==
1);
"SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") == 1);
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, preparation_id) == NULL);
@ -353,10 +360,8 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
strlen(LOCAL_READY_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(json_string(replayed,
"preparation_id",
local_preparation_id,
sizeof(local_preparation_id)));
CHECK(json_string(
replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id)));
CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
free(replayed);
replayed = NULL;
@ -372,6 +377,57 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
/* A completed occurrence hides the preparation by its reserved stable
* execution identity while retaining every preparation/delivery row. */
free(replayed);
replayed = NULL;
replayed_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-completed-create",
LOCAL_READY_BODY,
strlen(LOCAL_READY_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(json_string(
replayed, "preparation_id", local_preparation_id, sizeof(local_preparation_id)));
CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
free(replayed);
replayed = NULL;
replayed_size = 0U;
CHECK(trainlog_web_sessions_deliver_json(database,
local_preparation_id,
local_revision_id,
"request-completed-deliver",
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(json_string(
replayed, "execution_session_id", execution_session_id, sizeof(execution_session_id)));
free(page);
page = NULL;
page_size = 0U;
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, local_preparation_id) != NULL);
{
char *sql =
sqlite3_mprintf("INSERT INTO sessions(session_id,started_at,ended_at,session_type) "
"VALUES(%Q,'2026-09-20T10:00:00Z','2026-09-20T11:00:00Z','training')",
execution_session_id);
CHECK(sql != NULL);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
sqlite3_free(sql);
}
free(page);
page = NULL;
page_size = 0U;
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, local_preparation_id) == NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 3);
free(created);
free(delivery);
free(withdrawal);

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken, newRequestId, startSync } from './sync'
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
@ -9,6 +9,8 @@ export interface SessionListItem {
state: string
occurrence_count: number
sort_timestamp: string
editing_state?: 'draft' | 'ready' | null
delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled' | null
}
export interface SessionPage {
@ -52,6 +54,8 @@ export interface SessionDetail {
withdrawal_id?: string | null
withdrawal_acknowledged_at?: string | null
state: string
editing_state?: 'draft' | 'ready'
delivery_state?: 'local' | 'pending' | 'acknowledged' | 'remote_unknown' | 'cancelled'
occurrences: PreparationOccurrence[]
}
@ -83,7 +87,9 @@ function validListItem(value: unknown): value is SessionListItem {
return typeof value.identity === 'string' && typeof value.title === 'string' &&
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
typeof value.sort_timestamp === 'string'
typeof value.sort_timestamp === 'string' &&
(value.editing_state === undefined || value.editing_state === null || value.editing_state === 'draft' || value.editing_state === 'ready') &&
(value.delivery_state === undefined || value.delivery_state === null || ['local', 'pending', 'acknowledged', 'remote_unknown', 'cancelled'].includes(String(value.delivery_state)))
}
export async function fetchSessionPage(
@ -236,6 +242,11 @@ export async function prepareForAndroid(identity: string, revision: string): Pro
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
throw new Error(reason)
}
/* WHY: a durable delivery must become reachable before the desktop can be
* powered down. CONTRACT: admission reuses the one sync orchestrator and
* its global lock; failure never removes or duplicates the pending delivery.
* INVARIANT: the transport is not part of delivery identity. */
await startSync(newRequestId())
}
export interface PreparationWithdrawalResult {

View file

@ -37,6 +37,10 @@ export interface SyncStatus {
drafts?: SyncDraftSummary[]
started_at?: string | null
finished_at?: string | null
transport?: 'directory' | 'mtp' | 'drive'
usb_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed'
drive_state?: 'disabled' | 'unavailable' | 'running' | 'mirrored' | 'success' | 'failed'
drive_diagnostic?: string
}
let csrfToken = ''
@ -85,6 +89,10 @@ function parse(value: unknown): SyncStatus {
!optionalString(status.error_code) ||
!optionalString(status.started_at) ||
!optionalString(status.finished_at) ||
!optionalString(status.drive_diagnostic) ||
(status.transport !== undefined && !['directory', 'mtp', 'drive'].includes(status.transport)) ||
(status.usb_state !== undefined && !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.usb_state)) ||
(status.drive_state !== undefined && !['disabled', 'unavailable', 'running', 'mirrored', 'success', 'failed'].includes(status.drive_state)) ||
(status.progress_revision !== undefined &&
(!Number.isSafeInteger(status.progress_revision) || status.progress_revision < 0))) {
throw new Error('statut sync invalide')

View file

@ -28,7 +28,7 @@ const committedPhases = new Set([
function statusAction(status: SyncStatus | null): string {
if (status?.phase === 'waiting_android_publication') {
return 'Ouvrez l’écran de synchronisation Android et laissez le téléphone déverrouillé.'
return 'Demande envoyée ; Trainlog Android doit être joignable par le service de fond ou Drive.'
}
if (status?.phase === 'failed' && status.error_code === 'device_unavailable') {
return 'Connectez et déverrouillez le téléphone, puis réessayez.'
@ -135,6 +135,19 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
{action && <p>{action}</p>}
{status?.error_code && <p>Code : {status.error_code}</p>}
{status?.diagnostic && <p>{status.diagnostic}</p>}
{status?.usb_state && <p>Android USB : {{
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
success: '✓ synchronisé', failed: '⚠ non traité',
}[status.usb_state]}</p>}
{status?.drive_state && <p>Google Drive : {{
disabled: 'désactivé', unavailable: 'non disponible', running: 'en cours',
mirrored: '✓ miroir à jour', success: '✓ synchronisé', failed: '⚠ miroir non mis à jour',
}[status.drive_state]}</p>}
{status?.drive_diagnostic && <p>{status.drive_diagnostic}</p>}
{status?.phase === 'waiting_android_publication' && <p>Demande : envoyée · Android : génération attendue</p>}
{status?.inbound_generation_id && <p>PC : import terminé · <code>{status.inbound_generation_id}</code></p>}
{status?.outbound_generation_id && <p>PC : génération publiée · <code>{status.outbound_generation_id}</code></p>}
{status?.phase === 'completed' && <p>Android : import terminé · ACK : reçu · Résultat : terminé</p>}
{status?.sessions_reconciled !== null && status?.sessions_reconciled !== undefined && (
<p>Séances rapprochées : {status.sessions_reconciled}</p>
)}

View file

@ -498,6 +498,7 @@ export function SessionsPage() {
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [sendingPreparation, setSendingPreparation] = useState<string | null>(null)
const deleteReturnFocus = useRef<HTMLElement | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
@ -583,6 +584,36 @@ export function SessionsPage() {
setDeleting(false)
}
}
const sendPreparation = async (item: SessionListEntry) => {
if (sendingPreparation !== null || item.collection !== 'preparations' ||
item.delivery_state !== 'local') return
setSendingPreparation(item.identity)
setStatusMessage('')
try {
const detail = await fetchSessionDetail('preparation', item.identity)
let revision = detail.revision_id
if (detail.editing_state === 'draft') {
const saved = await savePreparation({
title: detail.title,
session_type: detail.session_type,
planned_for: detail.planned_for,
notes: detail.notes,
editing_state: 'ready',
occurrences: detail.occurrences.map(commandOccurrence),
}, detail.identity, detail.revision_id)
revision = saved.revision_id
}
await prepareForAndroid(detail.identity, revision)
setStatusMessage('Préparation à synchroniser vers Android.')
} catch (reason) {
setStatusMessage(reason instanceof Error
? `Envoi Android impossible : ${reason.message}`
: 'Envoi Android impossible.')
} finally {
setSendingPreparation(null)
load()
}
}
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -636,6 +667,15 @@ export function SessionsPage() {
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => {
const kind = deletionKind(item.collection)
const deliveryLabel = item.collection !== 'preparations' ? null
: item.delivery_state === 'pending' ? 'À synchroniser'
: item.delivery_state === 'acknowledged' ? 'Reçue sur Android'
: item.delivery_state === 'remote_unknown' ? 'État Android inconnu'
: item.delivery_state === 'cancelled' ? 'Livraison annulée'
: 'Locale'
const sendLabel = item.editing_state === 'draft'
? 'Marquer prête et envoyer vers Android'
: 'Envoyer vers Android'
return <div className="session-row" key={`${item.collection}-${item.identity}`}>
<button
type="button"
@ -649,8 +689,21 @@ export function SessionsPage() {
: 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{sessionStateLabel(item.collection, item.state)}</span>
<span>{item.collection === 'preparations'
? `${sessionStateLabel(item.collection, item.editing_state ?? item.state)} · ${deliveryLabel}`
: sessionStateLabel(item.collection, item.state)}</span>
</button>
{item.collection === 'preparations' && item.delivery_state === 'local' && <button
type="button"
className="android-send-action"
aria-label={sendLabel}
title={sendLabel}
disabled={sendingPreparation !== null}
onClick={() => void sendPreparation(item)}
><span aria-hidden="true">⇥</span></button>}
{item.collection === 'preparations' && item.delivery_state !== 'local' && <span
className={`android-delivery-state state-${item.delivery_state}`}
>{deliveryLabel}</span>}
<button
type="button"
className="trash-action"

View file

@ -91,7 +91,7 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
border: 1px solid var(--surface-1);
border-radius: .75rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 3rem;
grid-template-columns: minmax(0, 1fr) auto 3rem;
overflow: hidden;
width: 100%;
}
@ -166,6 +166,35 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
}
.trash-action:hover,
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
.android-send-action {
align-items: center;
align-self: stretch;
background: transparent;
border: 0;
border-left: 1px solid var(--surface-1);
color: var(--blue);
cursor: pointer;
display: inline-flex;
font-size: 1.25rem;
justify-content: center;
min-height: 44px;
min-width: 44px;
}
.android-send-action:hover, .android-send-action:focus-visible { background: rgb(137 180 250 / 12%); }
.android-send-action:disabled { cursor: wait; opacity: .55; }
.android-delivery-state {
align-items: center;
border-left: 1px solid var(--surface-1);
color: var(--subtext-0);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: 700;
max-width: 9rem;
padding: .55rem .75rem;
text-align: center;
}
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
.android-delivery-state.state-acknowledged { color: var(--green); }
.program-list, .program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }