## 📁 Reorganization - Moved all docs to docs/ folder (FEATURES, BACKUP, DESKTOP, DOCS) - Updated all cross-references in README.md/en - Fixed internal links in docs ## �� Corrections - FEATURES.md: Fixed build variants - both are 100% FOSS (no Google Services) - Clarified: App is completely FOSS with no proprietary libraries ## ✨ Changes - Web Editor moved from v1.6.0 to v1.3.0 (earlier implementation) - Combined with organization features (tags, search, sorting)
14 KiB
Simple Notes Sync - Technical Documentation
This file contains detailed technical information about implementation, architecture, and advanced features.
🌍 Languages: Deutsch · English
📐 Architecture
Overall Overview
┌─────────────────┐
│ Android App │
│ (Kotlin) │
└────────┬────────┘
│ WebDAV/HTTP
│
┌────────▼────────┐
│ WebDAV Server │
│ (Docker) │
└─────────────────┘
Android App Architecture
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 Implementation
WorkManager Periodic Task
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
Network Detection
Instead of SSID-based detection (Android 13+ privacy issues), we use Gateway IP Comparison:
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
Sync Flow
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)
<EFBFBD> Sync Trigger Overview
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 enabled/SSID changed | ✅ Yes |
Server Reachability Check (Pre-Check)
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
Sync Behavior by Trigger Type
| 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) | SSID-based |
<EFBFBD>🔋 Battery Optimization
Usage Analysis
| Component | Frequency | Usage | Details |
|---|---|---|---|
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
| Total | - | ~12 mAh/day | ~0.4% at 3000mAh |
Optimizations
-
IP Caching
private var cachedServerIP: String? = null // DNS lookup only once at start, not every check -
Throttling
private var lastSyncTime = 0L private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min -
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
📦 WebDAV Sync Details
Upload Flow
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
}
Download Flow
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)
}
Conflict Resolution
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)
}
🔔 Notifications
Notification Channels
val channel = NotificationChannel(
"notes_sync_channel",
"Notes Synchronization",
NotificationManager.IMPORTANCE_DEFAULT
)
Success Notification
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)
}
🛡️ Permissions
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!
Earlier versions required ACCESS_FINE_LOCATION for SSID detection. Now we use Gateway IP Comparison.
🧪 Testing
Test Server
# 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.json
Test Android App
Unit Tests:
cd android
./gradlew test
Instrumented Tests:
./gradlew connectedAndroidTest
Manual 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
🚀 Build & Deployment
Debug Build
cd android
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
Release Build
./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release-unsigned.apk
Sign (for Distribution)
# 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
🐛 Debugging
LogCat Filter
# 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.log
Common Issues
Problem: 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
📚 Dependencies
// 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.0
🔮 Roadmap
v1.1
- Search & Filter
- Dark Mode
- Tags/Categories
- Markdown Preview
v2.0
- Desktop Client (Flutter)
- End-to-End Encryption
- Shared Notes (Collaboration)
- Attachment Support
📖 Further Documentation
- Project Docs
- Sync Architecture - Detailed Sync Trigger Documentation
- Android Guide
- Bugfix Documentation
Last updated: December 25, 2025