# Simple Notes Sync - Technical Documentation This file contains detailed technical information about implementation, architecture, and advanced features. **๐ŸŒ Languages:** [Deutsch](DOCS.md) ยท **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: ```kotlin val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only .build() val syncRequest = PeriodicWorkRequestBuilder( 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 We use **Gateway IP Comparison** to check if the server is reachable: ```kotlin 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) ``` --- ## ๐Ÿ”„ 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 connected | โœ… Yes | ### Server Reachability Check (Pre-Check) **All 4 sync triggers** use a **pre-check** before the actual sync: ```kotlin // 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) | WiFi-based | --- ## ๐Ÿ”‹ 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 1. **IP Caching** ```kotlin private var cachedServerIP: String? = null // DNS lookup only once at start, not every check ``` 2. **Throttling** ```kotlin private var lastSyncTime = 0L private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min ``` 3. **Conditional Logging** ```kotlin object Logger { fun d(tag: String, msg: String) { if (BuildConfig.DEBUG) Log.d(tag, msg) } } ``` 4. **Network Constraints** - WiFi only (not mobile data) - Only when server is reachable - No permanent listeners --- ## ๐Ÿ“ฆ WebDAV Sync Details ### Upload Flow ```kotlin 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 ```kotlin 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** ```kotlin 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 ```kotlin val channel = NotificationChannel( "notes_sync_channel", "Notes Synchronization", NotificationManager.IMPORTANCE_DEFAULT ) ``` ### Success Notification ```kotlin 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**: ```xml ``` **No Location Permissions!** We use Gateway IP Comparison instead of SSID detection. No location permission required. --- ## ๐Ÿงช Testing ### Test Server ```bash # 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:** ```bash cd android ./gradlew test ``` **Instrumented Tests:** ```bash ./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 ```bash cd android ./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk ``` ### Release Build ```bash ./gradlew assembleRelease # APK: app/build/outputs/apk/release/app-release-unsigned.apk ``` ### Sign (for Distribution) ```bash # 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 ```bash # 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 ```gradle // 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](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync) - [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detailed Sync Trigger Documentation** - [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md) --- **Last updated:** December 25, 2025