Skip to content

Latest commit

ย 

History

History
631 lines (484 loc) ยท 19.8 KB

File metadata and controls

631 lines (484 loc) ยท 19.8 KB

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

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

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 (SharedFlow only) 15/30/60 min
WiFi-Connect Sync Silent 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

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

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

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 Breakdown

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

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

Download Flow

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

Conflict Resolution

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.

Not implemented (deliberately)

  • 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 the sync/ 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.

๐Ÿ”” 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 assembleFdroidDebug
# APK: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk

Release Build

./gradlew assembleFdroidRelease
# APK: app/build/outputs/apk/fdroid/release/app-fdroid-release.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

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

๐Ÿ”ฎ Roadmap

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


๐Ÿ“– Further Documentation


Last updated: June 2026