Simple Notes Sync - Technical Documentation

March 20, 2026 ยท View on GitHub

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 # 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
โ”‚   โ”œโ”€โ”€ 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 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

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

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:

TriggerFileFunctionWhen?Pre-Check?
1. Manual SyncComposeMainActivitytriggerManualSync()User clicks sync button in menuโœ… Yes
2. Auto-Sync (onResume)ComposeMainActivitytriggerAutoSync()App opened/resumedโœ… Yes
3. Background Sync (Periodic)SyncWorker.ktdoWork()Every 15/30/60 minutes (configurable)โœ… Yes
4. WiFi-Connect SyncNetworkMonitor.kt โ†’ SyncWorker.kttriggerWifiConnectSync()WiFi connectedโœ… 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

TriggerWhen server not reachableOn successful syncThrottling
Manual SyncToast: "Server not reachable"Toast: "โœ… Synced: X notes"None
Auto-Sync (onResume)Silent abort (no toast)Toast: "โœ… Synced: X notes"Max. 1x/min
Background SyncSilent abort (no toast)Silent (SharedFlow only)15/30/60 min
WiFi-Connect SyncSilent abort (no toast)Silent (SharedFlow only)WiFi-based

๐Ÿ”‹ Battery Optimization

v1.6.0: Configurable Sync Triggers

Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.

Sync Trigger Overview

TriggerDefaultBattery ImpactDescription
Manual SyncAlways on0 (user-triggered)Toolbar button / Pull-to-refresh
onSave Syncโœ… ON~0.5 mAh/saveSync immediately after saving a note
onResume Syncโœ… ON~0.3 mAh/resumeSync when app is opened (60s throttle)
WiFi-Connectโœ… ON~0.5 mAh/connectSync when WiFi is connected
Periodic SyncโŒ OFF0.2-0.8%/dayBackground sync every 15/30/60 min
Boot SyncโŒ OFF~0.1 mAh/bootStart background sync after reboot

Battery Usage Calculation

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):

IntervalSyncs/dayBattery/dayTotal (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 Breakdown

ComponentFrequencyUsageDetails
WorkManager WakeupPer sync~0.15 mAhSystem wakes up
Network CheckPer sync~0.03 mAhGateway IP check
WebDAV SyncOnly if changes~0.25 mAhHTTP PUT/GET
Per-Sync Total-~0.25 mAhOptimized

Optimizations

  1. 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
    
  2. Throttling

    • onResume: 60 second minimum interval
    • onSave: 5 second minimum interval
    • Periodic: 15/30/60 minute intervals
  3. IP Caching

    private var cachedServerIP: String? = null
    // DNS lookup only once at start, not every check
    
  4. Conditional Logging

    object Logger {
        fun d(tag: String, msg: String) {
            if (BuildConfig.DEBUG) Log.d(tag, msg)
        }
    }
    
  5. 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!
We use Gateway IP Comparison instead of SSID detection. No location permission required.


๐Ÿงช 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

// 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

๐Ÿ”ฎ Roadmap

See UPCOMING.md for the full roadmap and planned features.


๐Ÿ“– Further Documentation


Last updated: February 2026