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
โโโ sync/
โ โโโ WebDavSyncService.kt # WebDAV sync logic
โ โโโ NetworkMonitor.kt # WiFi detection
โ โโโ SyncWorker.kt # WorkManager background worker
โ โโโ BootReceiver.kt # Device reboot handler
โโโ adapters/
โ โโโ NotesAdapter.kt # RecyclerView adapter
โโโ utils/
โ โโโ Constants.kt # App constants
โ โโโ NotificationHelper.kt# Notification management
โ โโโ Logger.kt # Debug/release logging
โโโ activities/
โโโ MainActivity.kt # Main view with list
โโโ NoteEditorActivity.kt# Note editor
โโโ SettingsActivity.kt # Server configuration
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 | MainActivity.kt |
triggerManualSync() |
User clicks sync button in menu | โ Yes |
| 2. Auto-Sync (onResume) | MainActivity.kt |
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 (LocalBroadcast only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast 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"
sardine.put(remotePath, jsonContent.toByteArray())
note.syncStatus = SyncStatus.SYNCED
storage.saveNote(note)
uploadedCount++
}
}
return uploadedCount
}suspend fun downloadNotes(): DownloadResult {
val remoteFiles = sardine.list(serverUrl)
var downloadedCount = 0
var conflictCount = 0
for (file in remoteFiles) {
if (!file.name.endsWith(".json")) continue
val content = sardine.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.modifiedAt < remoteNote.modifiedAt) {
// Server has newer version
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt > remoteNote.modifiedAt) {
// Local version is newer โ Conflict
resolveConflict(localNote, remoteNote)
conflictCount++
}
}
return DownloadResult(downloadedCount, conflictCount)
}Strategy: Last-Write-Wins with Conflict Copy
fun resolveConflict(local: Note, remote: Note) {
// Rename remote note (conflict copy)
val conflictNote = remote.copy(
id = "${remote.id}_conflict_${System.currentTimeMillis()}",
title = "${remote.title} (Conflict)"
)
storage.saveNote(conflictNote)
// Local note remains
local.syncStatus = SyncStatus.SYNCED
storage.saveNote(local)
}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 assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release-unsigned.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
// Core
androidx.core:core-ktx:1.12.0
androidx.appcompat:appcompat:1.6.1
com.google.android.material:material:1.11.0
// Lifecycle
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0
// RecyclerView
androidx.recyclerview:recyclerview:1.3.2
// Coroutines
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
// WorkManager
androidx.work:work-runtime-ktx:2.9.0
// WebDAV Client
com.github.thegrizzlylabs:sardine-android:0.8
// Broadcast (deprecated but working)
androidx.localbroadcastmanager:localbroadcastmanager:1.1.0See UPCOMING.md for the full roadmap and planned features.
- Project Docs
- Sync Architecture - Detailed Sync Trigger Documentation
- Android Guide
- Bugfix Documentation
Last updated: February 2026