This file contains detailed technical information about implementation, architecture, and advanced features.
๐ Languages: Deutsch ยท English
โโโโโโโโโโโโโโโโโโโ
โ Android App โ
โ (Kotlin) โ
โโโโโโโโโโฌโโโโโโโโโ
โ WebDAV/HTTP
โ
โโโโโโโโโโผโโโโโโโโโ
โ WebDAV Server โ
โ (Docker) โ
โโโโโโโโโโโโโโโโโโโ
app/
โโโ models/
โ โโโ Note.kt # Data class for notes
โ โโโ SyncStatus.kt # Sync status enum
โโโ storage/
โ โโโ NotesStorage.kt # Local JSON file storage
โ โโโ FolderStore.kt # Folder definitions & metadata
โ โโโ TrashManager.kt # Trash / retention handling
โโโ noteimport/ # Notes import wizard (incl. Google Keep)
โโโ sync/
โ โโโ WebDavSyncService.kt # Sync facade (delegates to modules)
โ โโโ SyncGateChecker.kt # Pre-sync validation
โ โโโ ETagCache.kt # E-Tag caching
โ โโโ SyncTimestampManager.kt # Timestamp tracking
โ โโโ ConnectionManager.kt # HTTP connection lifecycle
โ โโโ NoteUploader.kt # Upload logic
โ โโโ NoteDownloader.kt # Download logic
โ โโโ MarkdownSyncManager.kt # Markdown bidirectional sync
โ โโโ FolderSyncManager.kt # Folder โ subdirectory sync
โ โโโ NetworkMonitor.kt # WiFi detection
โ โโโ SyncWorker.kt # WorkManager background worker
โ โโโ BootReceiver.kt # Device reboot handler
โโโ ui/
โ โโโ main/ # Main screen (Compose)
โ โโโ editor/ # Note editor (Compose)
โ โโโ settings/ # Settings screens (Compose)
โ โโโ widget/ # Homescreen widgets (Glance)
โโโ utils/
โโโ Constants.kt # App constants
โโโ NotificationHelper.kt# Notification management
โโโ Logger.kt # Debug/release logging
Auto-sync is based on WorkManager with the following configuration:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Every 30 minutes
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()Why WorkManager?
- โ Runs even when app is closed
- โ Automatic restart after device reboot
- โ Battery-efficient (Android managed)
- โ Guaranteed execution when constraints are met
We use Gateway IP Comparison to check if the server is reachable:
fun isInHomeNetwork(): Boolean {
val gatewayIP = getGatewayIP() // e.g. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // e.g. 192.168.0.188
return isSameNetwork(gatewayIP, serverIP) // Checks /24 network
}Advantages:
- โ No location permissions needed
- โ Works with all Android versions
- โ Reliable and fast
1. WorkManager wakes up (every 30 min)
โ
2. Check: WiFi connected?
โ
3. Check: Same network as server?
โ
4. Load local notes
โ
5. Upload new/changed notes โ Server
โ
6. Download remote notes โ Server
โ
7. Merge & resolve conflicts
โ
8. Update local storage
โ
9. Show notification (if changes)
The app uses 4 different sync triggers with different use cases:
| Trigger | File | Function | When? | Pre-Check? |
|---|---|---|---|---|
| 1. Manual Sync | ComposeMainActivity |
triggerManualSync() |
User clicks sync button in menu | โ Yes |
| 2. Auto-Sync (onResume) | ComposeMainActivity |
triggerAutoSync() |
App opened/resumed | โ Yes |
| 3. Background Sync (Periodic) | SyncWorker.kt |
doWork() |
Every 15/30/60 minutes (configurable) | โ Yes |
| 4. WiFi-Connect Sync | NetworkMonitor.kt โ SyncWorker.kt |
triggerWifiConnectSync() |
WiFi connected | โ Yes |
All 4 sync triggers use a pre-check before the actual sync:
// WebDavSyncService.kt - isServerReachable()
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 2000) // 2s Timeout
}
true
} catch (e: Exception) {
Logger.d(TAG, "Server not reachable: ${e.message}")
false
}
}Why Socket Check instead of HTTP Request?
- โก Faster: Socket connect is instant, HTTP request takes longer
- ๐ Battery Efficient: No HTTP overhead (headers, TLS handshake, etc.)
- ๐ฏ More Precise: Only checks network reachability, not server logic
- ๐ก๏ธ Prevents Errors: Detects foreign WiFi networks before sync error occurs
When does the check fail?
- โ Server offline/unreachable
- โ Wrong WiFi network (e.g. public cafรฉ WiFi)
- โ Network not ready yet (DHCP/routing delay after WiFi connect)
- โ VPN blocks server access
- โ No WebDAV server URL configured
| Trigger | When server not reachable | On successful sync | Throttling |
|---|---|---|---|
| Manual Sync | Toast: "Server not reachable" | Toast: "โ Synced: X notes" | None |
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "โ Synced: X notes" | Max. 1x/min |
| Background Sync | Silent abort (no toast) | Silent (SharedFlow only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (SharedFlow only) | WiFi-based |
Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.
| Trigger | Default | Battery Impact | Description |
|---|---|---|---|
| Manual Sync | Always on | 0 (user-triggered) | Toolbar button / Pull-to-refresh |
| onSave Sync | โ ON | ~0.5 mAh/save | Sync immediately after saving a note |
| onResume Sync | โ ON | ~0.3 mAh/resume | Sync when app is opened (60s throttle) |
| WiFi-Connect | โ ON | ~0.5 mAh/connect | Sync when WiFi is connected |
| Periodic Sync | โ OFF | 0.2-0.8%/day | Background sync every 15/30/60 min |
| Boot Sync | โ OFF | ~0.1 mAh/boot | Start background sync after reboot |
Typical usage scenario (defaults):
- onSave: ~5 saves/day ร 0.5 mAh = ~2.5 mAh
- onResume: ~10 opens/day ร 0.3 mAh = ~3 mAh
- WiFi-Connect: ~2 connects/day ร 0.5 mAh = ~1 mAh
- Total: ~6.5 mAh/day (~0.2% on 3000mAh battery)
With Periodic Sync enabled (15/30/60 min):
| Interval | Syncs/day | Battery/day | Total (with defaults) |
|---|---|---|---|
| 15 min | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
| 30 min | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
| 60 min | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
| Component | Frequency | Usage | Details |
|---|---|---|---|
| WorkManager Wakeup | Per sync | ~0.15 mAh | System wakes up |
| Network Check | Per sync | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | Only if changes | ~0.25 mAh | HTTP PUT/GET |
| Per-Sync Total | - | ~0.25 mAh | Optimized |
-
Pre-Checks before Sync
// Order matters! Cheapest checks first if (!hasUnsyncedChanges()) return // Local check (cheap) if (!isServerReachable()) return // Network check (expensive) performSync() // Only if both pass
-
Throttling
- onResume: 60 second minimum interval
- onSave: 5 second minimum interval
- Periodic: 15/30/60 minute intervals
-
IP Caching
private var cachedServerIP: String? = null // DNS lookup only once at start, not every check
-
Conditional Logging
object Logger { fun d(tag: String, msg: String) { if (BuildConfig.DEBUG) Log.d(tag, msg) } }
-
Network Constraints
- WiFi only (not mobile data)
- Only when server is reachable
- No permanent listeners
suspend fun uploadNotes(): Int {
val localNotes = storage.loadAllNotes()
var uploadedCount = 0
for (note in localNotes) {
if (note.syncStatus == SyncStatus.PENDING) {
val jsonContent = note.toJson()
val remotePath = "$serverUrl/${note.id}.json"
// v2.16.0: one PROPFIND per folder first โ if the server E-Tag no
// longer matches the cached one, this is a conflict and nothing is
// written. If-Match rides along as a second layer (see Conflict
// Resolution).
webdav.put(remotePath, jsonContent.toByteArray(), "application/json", ifMatch)
storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
uploadedCount++
}
}
return uploadedCount
}suspend fun downloadNotes(): DownloadResult {
val remoteFiles = webdav.list(serverUrl)
var downloadedCount = 0
var conflictCount = 0
for (file in remoteFiles) {
if (!file.name.endsWith(".json")) continue
val content = webdav.get(file.href)
val remoteNote = Note.fromJson(content)
val localNote = storage.loadNote(remoteNote.id)
if (localNote == null) {
// New note from server
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.updatedAt < remoteNote.updatedAt) {
// Server has the newer version. It only wins if the local copy holds
// no unuploaded edit โ otherwise this is a conflict (see below).
if (localNote.syncStatus.holdsLocalEdit) {
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
conflictCount++
} else {
storage.saveNote(remoteNoteFoldered.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
}
}
}
return DownloadResult(downloadedCount, conflictCount)
}Strategy: Last-Write-Wins, except where a local edit would be lost. There is no automatic merge and no conflict copy โ both are deliberate, see Not implemented below.
A conflict is detected in two places:
1. On upload (NoteUploader). Since v2.16.0, in two layers.
The layer that actually carries the guard is a PROPFIND before the first byte is written.
For every folder that holds a note to upload with a cached E-Tag, the uploader fetches the
current server E-Tags and compares them itself. A mismatch means the server copy changed since
this device last saw it โ the note is marked as a conflict and no PUT happens:
// NoteUploader.checkPreconditions()
if (isStaleAgainstServer(note, cachedETag, serverSnapshot)) {
return markConflict(note, storageMutex, why = "server_etag_changed")
}This has to happen client-side because it must not depend on a server feature: the server
recommended in server/README.md (hacdias/webdav on
golang.org/x/net/webdav) does not evaluate write preconditions โ a PUT with a wrong
If-Match answers 201 and overwrites. Measured for v2.16.0. Never make the conflict guard
depend on If-Match alone again.
The second layer is that If-Match precondition, sent with the PUT and effective on servers
that honour it (sabre/dav: Nextcloud, ownCloud, Baรฏkal). It closes the race between the PROPFIND
and the PUT:
// NoteUploader.uploadSingle()
val putEtag = try {
putWithPrecondition(webdav, noteUrl, jsonBytes, cachedETag)
} catch (e: WebDavException) {
if (e.statusCode == 412) return markConflict(note, storageMutex, why = "if_match_412")
throw e
}A server that cannot evaluate If-Match (400/501) gets one retry without the precondition,
and that is remembered for the server configuration โ the PROPFIND layer still guards it, and a
device that can no longer upload at all would be worse.
Cost: one PROPFIND per folder that has something to upload with a cached E-Tag. A no-op sync never reaches this point, and a first upload of fresh notes lists nothing โ without a cached E-Tag there is nothing to compare.
2. On download (NoteDownloader). The server copy is newer and the local note still holds
an edit that never reached the server (PENDING or CONFLICT). The local version is kept and
marked, the downloaded copy is discarded.
What a marked note does. Nothing, on purpose. The uploader only takes LOCAL_ONLY and
PENDING, so it is never pushed; since v2.16.0 the downloader no longer overwrites it either
(before that, the mark survived exactly one sync cycle and the local version was then silently
replaced). The note stays out of sync until a person decides. A notification is posted per sync
that detects conflicts, and the note carries a warning icon in the list.
Resolving it. Opening the note shows a banner in the editor with the two options โ the same
pair the desktop client offers as resolve_conflict(id, "keep_mine" | "use_server"):
| Action | What happens (SyncConflictResolver) |
|---|---|
| Keep mine | Cached E-Tag and content hash are dropped, the note goes back to PENDING. The next upload runs without a precondition and wins. |
| Use server version | The server copy is fetched with a single GET, replaces the local note as SYNCED, and is loaded into the open editor. |
- No conflict copy. Earlier revisions of this document described a
resolveConflict()that saved the remote version alongside the local one as "โฆ (Conflict)". No such function has ever existed in thesync/package. - No line-level three-way merge. It would require a common base revision, i.e. version history on the server, and still produce results nobody wants for prose. Keeping one version and letting a person choose solves the same problem for a fraction of the cost.
val channel = NotificationChannel(
"notes_sync_channel",
"Notes Synchronization",
NotificationManager.IMPORTANCE_DEFAULT
)fun showSyncSuccess(context: Context, count: Int) {
val intent = Intent(context, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAGS)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Sync successful")
.setContentText("$count notes synchronized")
.setContentIntent(pendingIntent) // Click opens app
.setAutoCancel(true) // Dismiss on click
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}The app requires minimal permissions:
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Boot Receiver -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Battery Optimization (optional) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />No Location Permissions!
We use Gateway IP Comparison instead of SSID detection. No location permission required.
# WebDAV server reachable?
curl -u noteuser:password http://192.168.0.188:8080/
# Upload file
echo '{"test":"data"}' > test.json
curl -u noteuser:password -T test.json http://192.168.0.188:8080/test.json
# Download file
curl -u noteuser:password http://192.168.0.188:8080/test.jsonUnit Tests:
cd android
./gradlew testInstrumented Tests:
./gradlew connectedAndroidTestManual Testing Checklist:
- Create note โ visible in list
- Edit note โ changes saved
- Delete note โ removed from list
- Manual sync โ server status "Reachable"
- Auto-sync โ notification after ~30 min
- Close app โ auto-sync continues
- Device reboot โ auto-sync starts automatically
- Server offline โ error notification
- Notification click โ app opens
cd android
./gradlew assembleFdroidDebug
# APK: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk./gradlew assembleFdroidRelease
# APK: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk# Create keystore
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
# Sign APK
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release-unsigned.apk my-alias
# Optimize
zipalign -v 4 app-release-unsigned.apk app-release.apk# Only app logs
adb logcat -s SimpleNotesApp NetworkMonitor SyncWorker WebDavSyncService
# With timestamps
adb logcat -v time -s SyncWorker
# Save to file
adb logcat -s SyncWorker > sync_debug.logProblem: Auto-sync not working
Solution: Disable battery optimization
Settings โ Apps โ Simple Notes โ Battery โ Don't optimize
Problem: Server not reachable
Check:
1. Server running? โ docker compose ps
2. IP correct? โ ip addr show
3. Port open? โ telnet 192.168.0.188 8080
4. Firewall? โ sudo ufw allow 8080
Problem: Notifications not appearing
Check:
1. Notification permission granted?
2. Do Not Disturb active?
3. App in background? โ Force stop & restart
// Kotlin 2.3.20
// Core
androidx.core:core-ktx:1.18.0
androidx.appcompat:appcompat:1.7.1
com.google.android.material:material:1.14.0
// Jetpack Compose (BOM) โ incl. Glance for widgets
androidx.compose:compose-bom:2026.05.01
// Lifecycle
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
// Coroutines
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0
// WorkManager
androidx.work:work-runtime-ktx:2.11.2
// JSON
com.google.code.gson:gson:2.14.0
// WebDAV client: own implementation since v2.14.0 (sync/webdav/, ~620 lines).
// The sardine-android dependency was removed with that release.
// HTTP transport: com.squareup.okhttp3:okhttp + com.burgstaller:okhttp-digestSee UPCOMING.md for the full roadmap and planned features.
- Project Docs
- Sync Architecture - Detailed Sync Trigger Documentation
- Android Guide
- Bugfix Documentation
Last updated: June 2026