@@ -52,6 +52,8 @@ class SyncManager @Inject constructor(
5252 companion object {
5353 private const val TAG = " SyncManager"
5454 const val SETTINGS_BACKUP_NAME = " eucplanet_settings.json"
55+ const val SETTINGS_BACKUP_PREFIX = " eucplanet_settings-"
56+ const val SETTINGS_BACKUP_SUFFIX = " .json"
5557 const val TRIPS_SUBFOLDER = " trips"
5658 const val UPLOAD_WORK_NAME = " trip_upload"
5759 }
@@ -337,33 +339,53 @@ class SyncManager @Inject constructor(
337339 }
338340
339341 /* * Serialise AppSettings + alarm rules to JSON and write to SETTINGS_BACKUP_NAME. */
340- suspend fun backupSettings (): Boolean {
342+ suspend fun backupSettings (): Boolean =
343+ backupSettingsAs(name = null , overwrite = true ) == BackupOutcome .Saved
344+
345+ /* *
346+ * Write a named backup file in the sync folder. [name] = null is the
347+ * default `eucplanet_settings.json`; a non-null sanitised name produces
348+ * `eucplanet_settings-{name}.json`. When [overwrite] is false and the
349+ * target already exists, returns [BackupOutcome.AlreadyExists] without
350+ * touching the file so the caller can prompt the rider.
351+ */
352+ suspend fun backupSettingsAs (name : String? , overwrite : Boolean ): BackupOutcome {
341353 val current = settingsRepository.get()
342- val folder = getSyncFolder(current) ? : return false
354+ val folder = getSyncFolder(current) ? : return BackupOutcome .Failed
355+ val fileName = buildBackupFileName(name)
356+ val existing = folder.findFile(fileName)
357+ if (existing != null && ! overwrite) return BackupOutcome .AlreadyExists
343358 val payload = SettingsJson .toJson(SettingsJson .stripDeviceBindings(current)).apply {
344359 put(" alarms" , alarmsToJson(alarmDao.getAll()))
345360 }
346361 val json = payload.toString(2 )
347362 return try {
348- val existing = folder.findFile(SETTINGS_BACKUP_NAME )
349363 existing?.delete()
350- val file = folder.createFile(" application/json" , SETTINGS_BACKUP_NAME ) ? : return false
364+ val file = folder.createFile(" application/json" , fileName)
365+ ? : return BackupOutcome .Failed
351366 context.contentResolver.openOutputStream(file.uri)?.use { out ->
352367 out .write(json.toByteArray(Charsets .UTF_8 ))
353- } ? : return false
354- settingsRepository.update(current.copy(lastSettingsBackupAt = System .currentTimeMillis()))
355- true
368+ } ? : return BackupOutcome .Failed
369+ // Only the default backup updates the "last backup" timestamp so
370+ // named snapshots don't reset the cadence indicator on the dashboard.
371+ if (name == null ) {
372+ settingsRepository.update(current.copy(lastSettingsBackupAt = System .currentTimeMillis()))
373+ }
374+ BackupOutcome .Saved
356375 } catch (e: Exception ) {
357376 Log .e(TAG , " Settings backup failed" , e)
358- false
377+ BackupOutcome . Failed
359378 }
360379 }
361380
362381 /* * Read settings.json from the folder and apply — keeps current syncFolder/device fields. */
363- suspend fun restoreSettings (): Boolean {
382+ suspend fun restoreSettings (): Boolean = restoreSettingsFrom(SETTINGS_BACKUP_NAME )
383+
384+ /* * Restore from the named backup file in the sync folder. */
385+ suspend fun restoreSettingsFrom (fileName : String ): Boolean {
364386 val current = settingsRepository.get()
365387 val folder = getSyncFolder(current) ? : return false
366- val file = folder.findFile(SETTINGS_BACKUP_NAME ) ? : return false
388+ val file = folder.findFile(fileName ) ? : return false
367389 return try {
368390 val bytes = context.contentResolver.openInputStream(file.uri)?.use { it.readBytes() }
369391 ? : return false
@@ -385,6 +407,52 @@ class SyncManager @Inject constructor(
385407 }
386408 }
387409
410+ /* *
411+ * List every settings backup in the sync folder. The default
412+ * `eucplanet_settings.json` is always returned first (with [BackupEntry.label]
413+ * = null), followed by named snapshots sorted by display label.
414+ */
415+ suspend fun listSettingsBackups (): List <BackupEntry > {
416+ val current = settingsRepository.get()
417+ val folder = getSyncFolder(current) ? : return emptyList()
418+ val out = mutableListOf<BackupEntry >()
419+ val named = mutableListOf<BackupEntry >()
420+ folder.listFiles().forEach { doc ->
421+ val n = doc.name ? : return @forEach
422+ if (! n.endsWith(" .json" , ignoreCase = true )) return @forEach
423+ when {
424+ n.equals(SETTINGS_BACKUP_NAME , ignoreCase = true ) -> {
425+ out + = BackupEntry (fileName = n, label = null )
426+ }
427+ n.startsWith(SETTINGS_BACKUP_PREFIX , ignoreCase = true ) &&
428+ n.length > SETTINGS_BACKUP_PREFIX .length + SETTINGS_BACKUP_SUFFIX .length -> {
429+ val label = n.substring(
430+ SETTINGS_BACKUP_PREFIX .length,
431+ n.length - SETTINGS_BACKUP_SUFFIX .length
432+ )
433+ if (label.isNotEmpty()) named + = BackupEntry (fileName = n, label = label)
434+ }
435+ }
436+ }
437+ named.sortBy { it.label?.lowercase() }
438+ return out + named
439+ }
440+
441+ /* * Path-safe sanitiser. Strips anything that isn't [A-Za-z0-9_- ], trims,
442+ * collapses whitespace, caps at 32 chars. Empty input returns null so the
443+ * caller can show a validation error. */
444+ fun sanitizeBackupName (raw : String ): String? {
445+ val cleaned = raw.trim()
446+ .replace(Regex (" [^A-Za-z0-9_\\ - ]" ), " " )
447+ .replace(Regex (" \\ s+" ), " " )
448+ .trim()
449+ .take(32 )
450+ return cleaned.takeIf { it.isNotEmpty() }
451+ }
452+
453+ private fun buildBackupFileName (name : String? ): String =
454+ if (name == null ) SETTINGS_BACKUP_NAME else " $SETTINGS_BACKUP_PREFIX$name$SETTINGS_BACKUP_SUFFIX "
455+
388456 /* * Enqueue the trip upload worker. */
389457 fun enqueueTripUpload (settings : AppSettings ) {
390458 if (settings.syncFolderUri == null ) return
@@ -501,3 +569,19 @@ class SyncManager @Inject constructor(
501569 return out
502570 }
503571}
572+
573+ /* *
574+ * Result of a settings-backup attempt. [AlreadyExists] is only ever returned
575+ * when the caller asked not to overwrite.
576+ */
577+ sealed interface BackupOutcome {
578+ data object Saved : BackupOutcome
579+ data object AlreadyExists : BackupOutcome
580+ data object Failed : BackupOutcome
581+ }
582+
583+ /* *
584+ * One row in the restore picker. [label] = null is the default backup
585+ * (`eucplanet_settings.json`); non-null is the rider-supplied snapshot name.
586+ */
587+ data class BackupEntry (val fileName : String , val label : String? )
0 commit comments