SFTPGo returns HTTP 423 (Locked) on MKCOL when a directory already
exists and has an active lock. ensureRemoteDirs only handled 405
(already exists), so 423 was thrown as an exception causing all file
uploads within that directory to fail.
65 files failed every time because they were all inside directories
that returned 423 on MKCOL, not 405. Treat 423 the same as 405.
Zahra's sync pair was configured with http://dav.khodak.me. Traefik has
a global HTTP->HTTPS redirect, but PROPFIND/PUT/MOVE are not followed
through redirects by OkHttp — so every WebDAV operation was getting
redirected and silently failing. 1072 logins, 0 actual DAV operations.
Silently rewrite http:// to https:// at the provider level so users
never need to reconfigure.
30s read/write timeout killed uploads of large video files mid-stream.
Videos in zahra's folders took 56s+ to upload — anything over 30s was
failing and counted as a failed file (PARTIAL). Raised to 5 minutes.
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.
A pushed git tag doesn't create a Gitea release object, so the publish step
404'd trying to attach the APK. Now it creates the release if absent (with
contents:write permission), then uploads. v1.0.65 was published manually.
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.
Code is publicly viewable and forkable for personal use, but redistribution,
publishing (any app store/release), and commercial use are prohibited — all
publishing rights reserved to the copyright holder. Combined with the private
release signing key, this keeps the app exclusively the owner's to publish.
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>