Previously every listFiles/uploadFile/downloadFile/deleteFile call created
a fresh SSH connection (connect → auth → use → disconnect). For zahra's
folder with 69 subdirectories, the recursive listing alone made 70 full
SSH handshakes, then one more per downloaded file — causing connection
timeouts and 65 upload/download failures reported as PARTIAL.
Now the provider holds a persistent SSH session and reuses it for all
calls, reconnecting automatically if the connection drops.
The remote was listed Depth:1 (top level only) while the local folder is
walked recursively. Files inside remote subfolders looked 'missing from
remote', so TWO_WAY + mirror-delete ran DELETE_LOCAL and wiped them off the
device. Now walk the remote tree (Depth:1 per dir) so subfolder files are
matched and never falsely deleted.
- Filter out isDirectory entries from remoteFiles so remote folders are
never treated as files to sync (fixes phantom-directory 'Partial ✗5' status)
- Lower Semaphore from 4 → 2 to reduce concurrent SFTP sessions and
avoid hitting server session limits
Creating an interval/daily/weekly sync pair saved it enabled but never enqueued
the periodic WorkManager job — it only scheduled on the enable-toggle or a
reboot, so a freshly-created scheduled backup silently never ran in the
background. AddPairViewModel.save now registers the work (periodic / watcher)
on save, mirroring toggleEnabled + BootReceiver. Verified on-device: the
JobScheduler periodic job appears on save and a forced run performs the sync.
Opt-in (-e bigFileMB=<size>): streams a multi-GB file from the device through
the app's chunked-upload path to the external nextcloud.khodak.me and verifies
the full size lands. Verified live: 1.5 GB and 5 GB both succeed end-to-end.
Tests the app's SFTPGo provider (WebDavProvider) end-to-end against a real
SFTPGo server over its exposed WebDAV URL: connect, mkdir, atomic upload,
list, download, overwrite, non-ASCII filename, delete. Validates the WebDAV
code path against a non-Nextcloud server. Creds via -e davUrl/davUser/davPass.
Big-file testing found single-PUT uploads 413 above the server's per-request
cap (Apache LimitRequestBody / PHP post_max_size / proxy limits). NextcloudProvider
now uploads files >chunkSize (100MB) via the dav/uploads chunked API: MKCOL a
session, PUT N chunks, then MOVE .file onto the destination (atomic assemble).
Bypasses any per-request cap so multi-GB files back up. Verified byte-exact
(multi-chunk) against live Nextcloud. SFTP already streams; single-PUT path
unchanged for <=100MB.
- Interruption: failed mid-write leaves original intact (no truncation, no temp
leftover); a sync that drops after N files resumes cleanly on the next sync
with all content byte-intact (real network-drop simulation).
- SFTP: live round-trip test against an SFTP server (connect/upload-atomic/
list/download/overwrite/special-name/delete); skips if endpoint unreachable.
- Scheduling: WorkManager request builders map Wi-Fi-only -> UNMETERED,
charging-only -> requiresCharging, interval, input data, and tags correctly.
Volume test (100 files) surfaced it: files with non-ASCII names (e.g. 'naïve
café.txt') failed to upload — url() built a raw string, so the MOVE Destination
header carried non-ASCII chars that OkHttp rejects. Now url() percent-encodes
each path segment via HttpUrl.addPathSegments (also covers '&', spaces, CJK).
Regression test specialAndNonAsciiNames_upload added.
RadioGroup rows only responded to taps on the ~144px radio dot; tapping the
label did nothing. Wrap each row in Modifier.selectable(role=RadioButton) with
RadioButton(onClick=null) so the whole row is one accessible tap target.
Verified on-device: tapping the 'Download only' label now selects it.
Full-matrix on-device test (FullSyncEngineTest) drives the real SyncEngine
(in-memory Room + real local folder + live Nextcloud) across all directions,
all delete behaviors, updates, recursive/non-recursive, filters, conflicts,
and content integrity — 14 instrumented tests, all green on a Galaxy S23.
It caught a real bug: ARCHIVE delete moved files to _Deleted/ but never
created the _Deleted folder, so the MOVE failed for top-level files and they
were left in place. Now creates the _Deleted base before the move.
Instrumented test driving the real NextcloudProvider over TLS: connect,
create dir, atomic upload (temp+MOVE), list+size, download+content, then the
backup guarantee — Upload-only + KEEP yields SKIP and the cloud copy is
verified still present; MIRROR yields DELETE_REMOTE and the real delete is
confirmed. Creds passed via instrumentation args (ncUrl/ncUser/ncPass), never
committed. Verified passing on a Galaxy S23 (Android 16) against live Nextcloud.
WebDAV already sanitizes server-supplied names, but SFTP passed entry.name
through unfiltered, and the engine had no central guard — a malicious or
compromised remote could return '../../x' and (on the JavaFile backend) write
outside the sync root.
- SyncEngine: isUnsafeSyncPath() rejects empty, absolute, and any '..'-segment
path; every file is checked before any read/write/delete (covers all providers).
- SftpProvider.listFiles: drop '.'/'..' and names containing path separators.
- PathSafetyTest covers traversal, backslash, absolute, and empty cases.
The Add-Pair screen defaulted deleteBehavior to MIRROR for every direction,
so an Upload-only backup would delete cloud files when you deleted them on
the phone. Now the default follows the direction:
- Upload-only / Download-only -> KEEP (deleting locally leaves the cloud copy)
- Two-way -> MIRROR
All three options remain selectable; once the user explicitly picks one,
changing direction no longer overrides it, and editing a saved pair keeps
its stored choice. Adds RecommendedDeleteBehaviorTest.
Characterizes the 'back up phone -> delete locally -> must stay in cloud'
scenario across the real multi-cycle engine state (upload saves null remote
metadata; next sync reconciles), asserting per delete behavior:
- KEEP -> SKIP (cloud copy retained) — correct backup behavior
- ARCHIVE -> DELETE_REMOTE decision (engine moves to _Deleted/, preserved)
- MIRROR -> DELETE_REMOTE (cloud copy wiped) — footgun, and the current default
Also: upload-only never pulls a new remote file down; local edits still upload.
These contradicted deliberate later safety fixes in syncDecide:
- sub-second mtime delta is now SKIP (second-precision comparison was the
fix for the FAT32/WebDAV phantom-change sync loops), not UPLOAD. Added a
full-second-delta case to keep change-detection coverage.
- remote file with no state record now DOWNLOADs instead of DELETE_REMOTE:
known==null can't be distinguished from a brand-new remote file, so the
engine never deletes on ambiguity. Genuinely-deleted local files still
have a state record and route to DELETE_REMOTE.
All 25 unit tests pass; assembleRelease builds and signs cleanly (compileSdk 35).
Sync engine / providers:
- LocalAccessor: replace createOutputStream with writeAtomically (temp
sibling + rename/commit) for both JavaFile and SAF backends, so an
interrupted download no longer truncates the destination file.
- SyncEngine: use writeAtomically for DOWNLOAD and propagate downloadFile
failures via getOrThrow (was silently swallowed -> false success + state).
- WebDavProvider (covers Nextcloud/ownCloud): PUT to hidden temp then MOVE
onto destination, so a failed upload can't leave a truncated remote file.
- SftpProvider: upload to temp then rename onto destination.
Build / CI:
- compileSdk 34 -> 35 (was below targetSdk 35).
- Release signing reads keystore from local.properties or env (CI), with a
debug-key fallback so builds still succeed without secrets.
- Disable R8/minify for release (never exercised by CI; keeps signed release
behaving like the debug builds in use today).
- CI: run unit tests on every push/PR, build assembleRelease (signed when
KEYSTORE_BASE64 present), publish APK only on v* tags.
- SyncEngine: accepts onProgress callback — emits uploaded/downloaded/
deleted/bytes counts atomically as each file completes
- SyncWorker: streams progress to WorkManager data so the UI can poll
it live; reports per-run counters in the completion notification;
adds pause/resume support
- HomeViewModel/PairDetailViewModel: subscribe to live WorkManager
progress and surface it via SyncProgress state
- SyncPairEntity/SyncPairDao/SyncDatabase: persist last-run counters
(uploaded, downloaded, deleted, bytesTransferred) in the DB with a
Room migration (v3→v4)
- AppModule: provides WorkManager as an injectable singleton
- .gitignore: add .kotlin/ to exclude compiler session files
Security: no new issues — all logging via Timber (debug-only), DB
queries use Room parameterized API, file sharing via FileProvider.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New PAUSED status. When a sync is running, the sync button becomes a
pause button (⏸). Tapping it cancels the WorkManager job and sets the
status to PAUSED (purple). The button then becomes a play button (▶) to
resume. Works in both the home screen card and the pair detail screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the synced-files list with a proper file explorer:
- Phone tab: browse all of internal storage with quick-access shortcuts
(Camera, Downloads, Documents, Pictures, Music, Videos), breadcrumb
navigation, search, tap folder to enter, tap file to open/share
- Cloud tab: browse connected cloud accounts, account switcher chips for
multiple accounts, breadcrumb navigation, search, tap file to
download+open
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tap on local folder opens the custom browser again (not system picker).
The custom browser already shows the All files access banner if needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove root folder block from the browser — user can now select
/storage/emulated/0 exactly like Autosync. If MANAGE_EXTERNAL_STORAGE
is not granted a red banner appears with a direct "Grant" button that
opens the Android All files access settings screen. Root guard removed
from SyncEngine; individual file failures (e.g. root-level writes) are
already caught and logged per-file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping the local folder field now opens Android's native folder picker
via ACTION_OPEN_DOCUMENT_TREE. The picked content:// URI gets persistent
read/write permission and is stored as-is; the existing Saf backend
handles all sync I/O through it. "Browse manually" link kept for the
raw-path custom browser.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New delete behavior option: "Archive deleted" — when a file is deleted
from the phone in a TWO_WAY pair, it moves to _Deleted/<path> on remote
instead of being permanently deleted from the backup.
Also allows storage root (/storage/emulated/0) for UPLOAD_ONLY pairs so
whole-phone backup syncs work; only blocks root when sync direction would
write files locally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android 11+ denies writes to /storage/emulated/0 directly. SyncEngine
now catches this early and returns FAILED with an actionable message
instead of silently logging PARTIAL. LocalBrowserDialog disables the
Select button at the storage root with an inline warning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android 15+ enforces edge-to-edge on Dialog windows, making standard
Compose WindowInsets APIs return 0 inside dialogs. Fix: use ViewCompat
insets listener inside the Dialog to read actual system bar heights,
with 56dp minimum to guarantee full nav bar clearance. Spacer inside
the button Surface lets the elevated background extend behind the nav
bar. Also make the entire local folder field tappable (not just the
trailing icon) for better UX.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace SAF document picker with custom LocalBrowserDialog (File API,
quick-access shortcuts, breadcrumb nav, search, folder-only listing)
- Rewrite RemoteBrowserDialog as full-screen dialog with breadcrumbs,
search, and new-folder creation; add navigateToBreadcrumb/createFolder
to RemoteBrowserViewModel
- Fix Select button cut off by navigation bar in both browsers: wrap
button in Column(navigationBarsPadding()) so the button sits above
the nav bar rather than behind it
- Tighten icon foreground crop to remove excess black border
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Restore mipmap-anydpi-v26 adaptive icon XMLs so Android 8+ shows
the icon at full size instead of scaling it to 66% safe zone
- Foreground at 108dp sizes (108-432px), background #050E05
- Status colors now semantic (not tied to app red/orange theme):
SUCCESS=green, SYNCING=blue, FAILED=red, PARTIAL=orange, CONFLICT=amber
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WebDavProvider: replace readBytes() with streaming RequestBody
(Okio sink.writeAll) so large files (1+ GB) upload without
allocating the full file in heap — fixes PARTIAL sync status
- App icon: replace vector XML with PNG mipmaps generated directly
from the user-provided reference image at all densities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upload rollback fix: after uploading a file, do not store the remote
mtime/etag from the upload response PROPFIND. Nextcloud and other
WebDAV servers can change a file's Last-Modified or ETag after upload
(thumbnail generation, checksums, folder aggregation). Storing stale
metadata caused the next sync to see remoteChanged=true and download
the file back, reverting the upload. Leaving remoteAfterTransfer=null
forces the SKIP reconciliation pass to fill in remote state from the
directory listing, which is the same source all future syncs use.
Icon: update foreground to thick ribbons with 3D highlight stripes
(blue/green/red/orange, width 12 + highlight 5); update background
to dark space theme with star dots.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs fixed:
1. catchupScan used raw dir.walk() with no filters, causing hidden/excluded
files to appear as "new" every startup and trigger a catchup sync.
Fixed by using LocalAccessor.walkFiles(pair) which applies the same
filters and uses the same mtime source (SAF cursor) as SyncEngine.
2. catchupScan compared localModifiedAt.toEpochMilli() vs File.lastModified()
(millisecond precision) while SyncEngine uses second precision. Every file
appeared "modified" after a successful sync. Fixed by using epochSecond.
3. syncDecide() treated !localExists && remoteExists && known==null as
"user deleted local copy → delete remote" even on files that were never
synced. Fixed to treat unknown remote files as new (download them), which
is safe because a genuinely-deleted file will always have a known state
record from the previous sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: manual sync triggered from the UI had no cooldown set in
FileWatchService, so file writes during any manual sync fired FileObserver
→ debounce → another sync → loop.
Fix: startSyncMonitor() subscribes to getWorkInfosByTagFlow("sync_$pairId")
and watches ALL sync work for each pair — manual, catchup, onchange — via
the tag that SyncWorker.buildOneTimeRequest() always adds.
- When any sync is RUNNING or ENQUEUED: cooldown extended to now+120s
- When sync transitions from running to finished: 60s settle cooldown
- Monitor job stored in syncMonitorJobs map and cancelled in clearWatchers()
This means no matter what triggers a sync, FileObserver events from the
resulting file writes are always suppressed until the folder settles.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three additional fixes found via live device logs:
1. Startup race window: FileObserver fires immediately after
startWatching() before catchupScan coroutine runs, starting a 5s
debounce with cooldown=0. Fixed by setting a 15s startup cooldown
in watchPath() BEFORE calling watchDirRecursive.
2. Stale debounce bypass: debounce job started with cooldown=0 fires
5s later even after catchupScan has already set cooldown and started
a catchup sync. Fixed by re-checking cooldown after the 5s delay
and aborting if already active.
3. Debounce not cancelled by catchupScan: if a debounce was queued
before catchupScan ran, catchupScan would enqueue a catchup sync
AND the old debounce would fire 5s later enqueuing a second sync.
Fixed by cancelling pending debounce in catchupScan before enqueue.
Icon: four thick arcs (blue/red/green/orange) in a 4-way pinwheel
with over/under ordering. White sync-arrow circle at center.
Pure black background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three root causes found via live logcat on device:
1. concurrent refresh() race: onStartCommand received twice causes two
refresh() coroutines to run in parallel, doubling FileObserver and
catchupScan registrations. Fixed with Mutex.withLock on refresh().
2. catchupScan no cooldown: catchup syncs write files but never set
syncCooldownUntil, so every written file immediately re-triggers
onChangeDetected. Fixed by setting cooldown before enqueue and
watching work completion same as onChangeDetected does.
3. CancellationException caught silently: exception handler
catch(_: Exception) was catching CancellationException and resetting
cooldown to 0L, re-opening the loop. Fixed by rethrowing
CancellationException and setting 60s cooldown on other errors.
Icon: interlocked rings (blue/red/green/orange) with sync arrow at
center, pure black background — matches reference image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SyncEngine: self-healing stale folder state detection (isRetry) wipes
orphaned SyncFileStateEntity records when localPath changes without a
pair re-save — prevents repeated DELETE_REMOTE on 32 old files
- SyncEngine: second-precision mtime comparison (/ 1000 / .epochSecond)
eliminates phantom localChanged=true from FAT32/WebDAV precision mismatch
- FileWatchService: syncCooldownUntil map suppresses FileObserver events
for 120s after sync starts and 60s after it finishes, breaking the
download→FileObserver→sync→download feedback loop
- Icon: three bold teardrop shapes (teal/red/amber) rotated 0/120/240°
on dark charcoal background with white cloud at intersection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sync loop root-cause fixes (three independent bugs):
1. Folder change clears stale file states (AddPairViewModel): when
localPath or remotePath changes on an existing pair, all
SyncFileStateEntity records are wiped. Previously those stale records
caused every sync to attempt DELETE_REMOTE on the old folder's files
and to treat all new-folder files as changed — causing both the
"deleting 32 files" loop and rewrites on every run.
2. Download stores null localModifiedAt (SyncEngine): SAF document
cursors can return a stale mtime immediately after a write. Storing
null forces the SKIP reconciliation pass on the next sync to read
the actual walkFiles cursor value, breaking the download->changed->
download loop caused by mtime inconsistency.
3. Second-precision mtime comparison (syncDecide): WebDAV RFC-1123 has
1-second precision; FAT32 has 2-second precision. Comparing at
millisecond level caused phantom "changed" detections after syncing
to/from these systems. Now uses epochSecond for both local and remote.
Icon: three bold teal/red/yellow teardrop streaks (Avast palette) flying
into a white cloud centre, on dark charcoal background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Icon: two thick tube-style arcs with 3D glossy highlights.
Arc 1 (left side): coral #E8665A to orange #E8A040
Arc 2 (right side): steel blue #4A7FD4 to deep purple #7B5EA7
Arrowheads: orange and purple. Background: dark purple-black.
Inspired by the braided knot color palette.
Fix "media not found" when opening photos:
- Intent now sets ClipData alongside FLAG_GRANT_READ_URI_PERMISSION
so the permission correctly propagates through the system chooser
to whichever app the user picks.
- openFile() and downloadToCache() both call MediaScannerConnection
so newly synced/downloaded files appear in gallery MediaStore index
before the viewer launches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.
Icon: white cloud body with two cyan/teal circular sync arcs (AutoSync
style), deep blue-to-teal gradient background.
Security review clean: no hardcoded credentials, cleartext blocked by
network_security_config, allowBackup=false, path traversal guards in
place on both server responses and local resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Icon: three identical parallel arcing arrows (same bezier curve, same blue-to-teal
gradient #64C8FF→#32EDBB, same arrowhead geometry) — visually cohesive and clearly
visible against the near-black background.
FileWatchService: FileObserver is now recursive — watchDirRecursive() creates an
observer for each subdirectory at startup, and adds new watchers when CREATE events
produce new directories. Fixes files added to subdirectories not being detected.
FilesViewModel: openFile/shareFile now fall back to download-then-open when the file
is absent locally. AccountRepository + ProviderFactory injected; downloads to
context.cacheDir/syncflow_open/ with isDownloading state. Path traversal guard added
(reject relativePath containing ".."). file_paths.xml gains cache-path entry.
WebDavProvider: path-traversal guard in parsePropfind — skip any server-returned
filename containing "..", "/" or "\". Replace android.util.Log with Timber so debug
logs are stripped from release builds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FilesScreen: per-file context menu (Open, Share, Rename, Delete), rename dialog,
delete confirmation, FileProvider-based open/share intents, Snackbar error feedback
- FilesViewModel: FileAction sealed class + SharedFlow; openFile, shareFile,
deleteFile, renameFile with DB cleanup; resolveFile handles SAF primary: URIs
- FileWatchService: stopWithTask=false keeps watcher alive after app swipe-away;
catchupScan on startup detects changes missed while service was not running;
SyncFileStateDao injected; FileObserver used for real-path SAF URIs
- BootReceiver: handles MY_PACKAGE_REPLACED to restart service after app update
- file_paths.xml: added external-path so FileProvider can serve /storage/emulated/0 files
- ic_launcher_foreground: three curved stroke-based arrows (quadratic bezier, round caps)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix SYNC_COMPLETED showing ↑0 ↓0 ✗0 when only deletions occurred: add ✕N
for deleted files to the summary message (↑N ↓N ✕N ✗N format)
- Fix PairDetail Activity section showing raw "SYNC_STARTED" enum names and
"remote" as a plain subtitle: replace dot-based EventRow with the same
polished icon-bubble rows as the global Log tab
- Extract shared SyncEventRow composable + iconAndTint/label helpers to
ui/shared/SyncEventRow.kt; both LogScreen and PairDetailScreen now use it
- Add Files tab (4th tab between Log and Accounts): folder browser showing
all synced files per pair, grouped by subdirectory, with file-type icons,
size, last-synced date, and a summary header (N files, total size)
- Add SyncFileStateDao.observeForPair() reactive Flow query for Files tab
- Completely redesign app icon: near-black radial gradient background with
three bold directional arrows in an S-pattern (coral → silver → teal),
each with gradient fills and tip-glow dots — entirely different from the
typical circular sync-arrow style
- Bump version to 1.0.22 (build 23)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix
for notifications never appearing on Android 13+ phones including Android 16)
- Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead
of lazily inside workers
- Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type
for Android 16 foreground service compatibility
- Add global activity Log tab (new tab 2 in main nav) showing all sync events
across all pairs, grouped by date with pair name, event icon, and file detail
- Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only
fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/*
tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection
- Bump version to 1.0.21 (build 22)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CRITICAL
- SftpProvider: replace PromiscuousVerifier with TofuHostKeyVerifier
(trust-on-first-use; stores SHA-256 fingerprints in EncryptedSharedPreferences;
rejects key changes on subsequent connections)
HIGH
- GoogleDriveProvider: replace raw string interpolation with buildJsonObject
in uploadFile, createDirectory, and moveFile to prevent JSON injection
- DropboxProvider: replace all raw JSON strings and Dropbox-API-Arg headers
with buildJsonObject for the same reason
- OAuthHelper: add cryptographically random state parameter to Dropbox and
OneDrive authorization URLs (stored alongside the PKCE verifier)
- OAuthRedirectActivity: validate returned state against stored value before
exchanging the authorization code (CSRF protection)
MEDIUM
- WebDavProvider: block cross-host redirects in the manual redirect interceptor
so Authorization headers are never forwarded to a different server
- AccountSetupScreen: set FLAG_SECURE on the window while credential fields
are visible to prevent screenshots and screen-recording capture
- libs.versions.toml: security-crypto alpha06 → stable 1.0.0;
biometric-ktx alpha05 → biometric 1.1.0 (stable, non-ktx artifact matches
the BiometricManager/BiometricPrompt API actually used in MainActivity)
- CredentialStore: migrate to security-crypto 1.0.0 API (MasterKeys.getOrCreate
+ positional create() args); add saveHostKey/getHostFingerprint for SFTP TOFU
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Start FileWatchService from SyncFlowApp.onCreate() for any existing
enabled ON_CHANGE pairs — previously the watcher only started on
device boot or explicit pair toggle, so existing pairs after an
app update never got watched
- About screen now shows "Version X.Y.Z (build N)" updating
automatically from BuildConfig on every release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>