fix: don't let pre-restore auto-export clobber the WebDAV backup being restored - #57
Merged
Merged
Conversation
…g restored When restoring into the currently open library with auto-export enabled, _restoreWebDavBackupInternal ran _persistOpenDatabaseState (which triggers an auto-export) before downloading the remote backup. That upload overwrote the exact canonical file the restore was about to read, archiving the real newer backup away under a timestamp and leaving the "restore" silently re-import the device's own stale data. Skip the auto-export step when the restore destination is the currently open library.
Add a DeviceIdentityService that generates a stable per-install device
id and lets the user set a friendly device name (defaulting to a
generic, non-identifying platform label rather than the OS hostname).
Every export now embeds deviceId/deviceName in the backup manifest and
uploads a small .meta.json sidecar next to the backup (kept in sync
through archive/rename/prune), so the device that produced a backup
can be shown without downloading the whole archive. Surfaced in the
WebDAV restore picker, the settings backup list, and the auto-import
prompt ("Backup exported from Kitchen iPad on ..."), plus a new
"Device name" field in WebDAV settings.
Two devices exporting at the same moment can interleave the
upload/archive/rename/prune steps in exportBackupToWebDav and corrupt
the backup version history. Add a lock file
(<serverPath>/.classi-sync.lock, holding {deviceId, acquiredAt} JSON)
that exportBackupToWebDav acquires before touching any backup files
and releases in a finally block.
WebDAV servers vary in support for native LOCK/UNLOCK (RFC 4918), so
this avoids that entirely and uses a plain file any WebDAV server can
store: write our claim, wait a short random jitter, then read back to
confirm we're still the recorded owner. It's advisory, not a true
compare-and-swap, but catches the common case of two devices syncing
within moments of each other. A lease expires an abandoned lock left
by a crashed app rather than wedging the folder permanently.
Callers get a distinct WebDavSyncBusyException (surfaced as
'backup_export_busy') instead of a generic export failure when
another device currently holds the lock.
Add revision tracking so exportBackupToWebDav can tell whether the remote backup moved on since this device last synced. Each export embeds a fresh revision token (plus the parentRevision it was based on) in the manifest and .meta.json sidecar. AppSessionController persists the last known revision per library (new LibraryBackupPreferencesService.lastKnownRevision) and passes it as parentRevision on every export, updating it after a successful export or restore. If the remote's current revision doesn't match parentRevision, another device pushed a change this device never saw. Rather than clobbering it, the export is written as a separate `_CONFLICT_<timestamp>` copy and a WebDavSyncConflictException is thrown; the canonical backup is left untouched. AppSessionController surfaces this as a distinct 'backup_export_conflict' message pointing the user at the backup list to reconcile manually, rather than a generic export failure. A remote backup with no revision at all (nothing uploaded yet, or a legacy backup predating revision tracking) is not treated as a conflict, so this doesn't block export against pre-existing backups.
…atus button The background/lock-triggered auto-export isn't reliable on every platform: Android can suspend the process shortly after it's backgrounded, cutting off the in-flight export (checkpoint, sync lock, zip, upload, archive, prune) before it finishes, while desktop platforms keep running normally when unfocused. Add a periodic timer (default 10 min, injectable via AppSessionController's new periodicExportInterval param) that opportunistically re-exports while the app is open and unlocked, independent of backgrounding, so the same code path works identically on every platform and the backup is never more than one interval stale even when the background trigger gets killed. The timer starts/stops alongside WebDAV auto-export eligibility (configured + enabled) and the session's ready/not-ready transitions. Also add a persistent backup status indicator to the main app shell (NavigationRail trailing slot on desktop, a strip above the bottom NavigationBar on mobile) showing at a glance whether the backup is current, syncing, behind, or failed (busy/conflict/generic), with a tap-to-export-now action reusing the existing exportNow() flow. Only shown once WebDAV auto-export is configured and enabled, matching the existing Settings screen's "Export now" gating.
Applied the analyzer's own suggested fix for use_null_aware_elements in the new WebDAV device/revision manifest code, replacing if (x != null) 'key': x with 'key': ?x. No behavior change.
…table scrolling Wrap timeframe grades, student detail, and student summary screens in ContentConstraints so their content width matches the rest of the app instead of stretching full-bleed. Convert the group detail screen's timeframe and session DataTables to ellipsis-safe Row/Expanded layouts so tables never scroll horizontally, and add defensive maxLines/ellipsis to labels and table headers that could otherwise wrap to a second line. Also fix a ChangeNotifier-used-after-dispose crash in the periodic export timer: an in-flight export tick could call notifyListeners() after the session controller was disposed.
Add isExpanded: true to DropdownButtonFormFields whose selected-item text can exceed the field width (grade scale, list scope, grade category, inactivity timeout) so the dropdown properly constrains to the available width instead of overflowing its RenderFlex. Also add maxLines/ellipsis to the underlying item labels so long group, category, or list names truncate instead of wrapping or overflowing.
Delete grade_entry_screen.dart and group_tracking_screen.dart, both unreachable from any route and unreferenced anywhere else in the app. Fix the failing lesson_repository_test: watchGroupEntryCategories reads from sessions_table, but the test only ever wrote to grade_entries_table via GradeRepository.saveEntry, so the query returned nothing. The real app creates the corresponding session row via SessionRepository.upsertSession before entering grades (see lesson_mode_screen.dart); the test now does the same.
Replace (_, __) error-callback parameters with (_, _) using Dart's wildcard pattern support, matching the codebase's lint rules.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When restoring into the currently open library with auto-export enabled,
_restoreWebDavBackupInternal ran _persistOpenDatabaseState (which triggers
an auto-export) before downloading the remote backup. That upload
overwrote the exact canonical file the restore was about to read, archiving
the real newer backup away under a timestamp and leaving the "restore"
silently re-import the device's own stale data. Skip the auto-export step
when the restore destination is the currently open library.