diff --git a/.gitignore b/.gitignore
index aa724b77..b0627bf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,12 @@
*.iml
.gradle
/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
+/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+.claude-context.md
+comments.md
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 2aae0e00..00000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-Notable
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee8..00000000
--- a/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
deleted file mode 100644
index f1fdb4fb..00000000
--- a/.idea/androidTestResultsUserPreferences.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b86273d9..00000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index e7f568f8..00000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 639c779c..00000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 7061a0d6..00000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index 5be13229..00000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
deleted file mode 100644
index f8051a6f..00000000
--- a/.idea/migrations.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index b2c751a3..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1d..00000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
deleted file mode 100644
index 539e3b80..00000000
--- a/.idea/studiobot.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddf..00000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 9fe65cc1..e37114ca 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -207,6 +207,11 @@ dependencies {
ksp "com.google.dagger:hilt-compiler:2.59.2"
implementation "androidx.hilt:hilt-navigation-compose:1.3.0"
+ // WebDAV sync dependencies
+ implementation "androidx.work:work-runtime-ktx:2.9.0" // Background sync
+ implementation "com.squareup.okhttp3:okhttp:4.12.0" // HTTP/WebDAV client
+ implementation "androidx.security:security-crypto:1.1.0-alpha06" // Encrypted credential storage
+
// Added so that R8 optimization don't fail
// SLF4J backend: provides a simple logger so (probably) ShipBook can output logs.
// Needed because SLF4J is just an API; without a binding, logging calls are ignored.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b39e7e33..f6f7f3d5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
= 15 minutes due to WorkManager/JobScheduler minimum interval
+ val syncInterval: Int = 15, // minutes
+ val lastSyncTime: String? = null,
+ val syncOnNoteClose: Boolean = true,
+ val wifiOnly: Boolean = false,
+ // Track which notebooks we've successfully synced to detect deletions
+ val syncedNotebookIds: Set = emptySet()
+)
+
+
@Serializable
data class AppSettings(
// General
@@ -47,7 +64,10 @@ data class AppSettings(
val twoFingerSwipeRightAction: GestureAction? = defaultTwoFingerSwipeRightAction,
val holdAction: GestureAction? = defaultHoldAction,
val enableQuickNav: Boolean = true,
+ val renameOnCreate: Boolean = true,
+ // Sync
+ val syncSettings: SyncSettings = SyncSettings(),
// Debug
val showWelcome: Boolean = true,
diff --git a/app/src/main/java/com/ethran/notable/data/db/Folder.kt b/app/src/main/java/com/ethran/notable/data/db/Folder.kt
index 95dbea8d..1dcdab92 100644
--- a/app/src/main/java/com/ethran/notable/data/db/Folder.kt
+++ b/app/src/main/java/com/ethran/notable/data/db/Folder.kt
@@ -49,6 +49,8 @@ interface FolderDao {
@Query("SELECT * FROM folder WHERE id IS :folderId")
fun getLive(folderId: String): LiveData
+ @Query("SELECT * FROM folder")
+ fun getAll(): List
@Insert
suspend fun create(folder: Folder): Long
@@ -74,6 +76,10 @@ class FolderRepository @Inject constructor(
db.update(folder)
}
+ fun getAll(): List {
+ return db.getAll()
+ }
+
fun getAllInFolder(folderId: String? = null): LiveData> {
return db.getChildrenFolders(folderId)
}
diff --git a/app/src/main/java/com/ethran/notable/data/db/Notebook.kt b/app/src/main/java/com/ethran/notable/data/db/Notebook.kt
index 4772ed06..821c7d7d 100644
--- a/app/src/main/java/com/ethran/notable/data/db/Notebook.kt
+++ b/app/src/main/java/com/ethran/notable/data/db/Notebook.kt
@@ -50,6 +50,9 @@ interface NotebookDao {
@Query("SELECT * FROM notebook WHERE parentFolderId is :folderId")
fun getAllInFolder(folderId: String? = null): LiveData>
+ @Query("SELECT * FROM notebook")
+ fun getAll(): List
+
@Query("SELECT * FROM notebook WHERE id = (:notebookId)")
fun getByIdLive(notebookId: String): LiveData
@@ -78,6 +81,10 @@ class BookRepository @Inject constructor(
) {
private val log = ShipBook.getLogger("BookRepository")
+ fun getAll(): List {
+ return notebookDao.getAll()
+ }
+
suspend fun create(notebook: Notebook) {
notebookDao.create(notebook)
val page = Page(
@@ -101,6 +108,14 @@ class BookRepository @Inject constructor(
notebookDao.update(updatedNotebook)
}
+ /**
+ * Update notebook without modifying the timestamp.
+ * Used during sync when downloading from server to preserve remote timestamp.
+ */
+ suspend fun updatePreservingTimestamp(notebook: Notebook) {
+ notebookDao.update(notebook)
+ }
+
fun getAllInFolder(folderId: String? = null): LiveData> {
return notebookDao.getAllInFolder(folderId)
}
diff --git a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
index f458f694..f2547f95 100644
--- a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
+++ b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
@@ -18,6 +18,9 @@ import com.ethran.notable.editor.utils.offsetStroke
import com.ethran.notable.editor.utils.refreshScreen
import com.ethran.notable.editor.utils.selectImagesAndStrokes
import com.ethran.notable.editor.utils.strokeBounds
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.sync.SyncEngine
+import com.ethran.notable.sync.SyncLogger
import com.ethran.notable.ui.showHint
import io.shipbook.shipbooksdk.ShipBook
import kotlinx.coroutines.CoroutineScope
@@ -34,7 +37,9 @@ class EditorControlTower(
private val scope: CoroutineScope,
val page: PageView,
private var history: History,
- private val state: EditorState
+ private val state: EditorState,
+ private val context: Context,
+ private val appRepository: AppRepository
) {
private var scrollInProgress = Mutex()
private var scrollJob: Job? = null
@@ -90,6 +95,15 @@ class EditorControlTower(
* @param id The unique identifier of the page to switch to.
*/
private suspend fun switchPage(id: String) {
+ // Trigger sync on the page we're leaving (if enabled)
+ val settings = GlobalAppSettings.current
+ if (settings.syncSettings.syncEnabled && settings.syncSettings.syncOnNoteClose) {
+ val oldPageId = page.currentPageId
+ scope.launch(Dispatchers.IO) {
+ triggerSyncForPage(oldPageId)
+ }
+ }
+
// Switch to Main thread for Compose state mutations
withContext(Dispatchers.Main) {
state.viewModel.changePage(id)
@@ -102,6 +116,23 @@ class EditorControlTower(
}
}
+ /**
+ * Trigger sync for a specific page's notebook.
+ */
+ private suspend fun triggerSyncForPage(pageId: String?) {
+ if (pageId == null) return
+
+ try {
+ val pageEntity = appRepository.pageRepository.getById(pageId) ?: return
+ pageEntity.notebookId?.let { notebookId ->
+ SyncLogger.i("EditorSync", "Auto-syncing on page close")
+ SyncEngine(context).syncNotebook(notebookId)
+ }
+ } catch (e: Exception) {
+ SyncLogger.e("EditorSync", "Auto-sync failed: ${e.message}")
+ }
+ }
+
fun setIsDrawing(value: Boolean) {
if (state.isDrawing == value) {
logEditorControlTower.w("IsDrawing already set to $value")
diff --git a/app/src/main/java/com/ethran/notable/editor/EditorView.kt b/app/src/main/java/com/ethran/notable/editor/EditorView.kt
index 50608bdd..38c481f0 100644
--- a/app/src/main/java/com/ethran/notable/editor/EditorView.kt
+++ b/app/src/main/java/com/ethran/notable/editor/EditorView.kt
@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.ethran.notable.data.AppRepository
import com.ethran.notable.data.datastore.EditorSettingCacheManager
+import com.ethran.notable.data.datastore.GlobalAppSettings
import com.ethran.notable.editor.canvas.CanvasEventBus
import com.ethran.notable.editor.state.EditorState
import com.ethran.notable.editor.state.History
@@ -33,6 +34,8 @@ import com.ethran.notable.gestures.EditorGestureReceiver
import com.ethran.notable.io.ExportEngine
import com.ethran.notable.io.exportToLinkedFile
import com.ethran.notable.navigation.NavigationDestination
+import com.ethran.notable.sync.SyncEngine
+import com.ethran.notable.sync.SyncLogger
import com.ethran.notable.ui.LocalSnackContext
import com.ethran.notable.ui.SnackConf
import com.ethran.notable.ui.SnackState
@@ -57,12 +60,8 @@ object EditorDestination : NavigationDestination {
const val PAGE_ID_ARG = "pageId"
const val BOOK_ID_ARG = "bookId"
- // Unified route: editor/{pageId}?bookId={bookId}
val routeWithArgs = "$route/{$PAGE_ID_ARG}?$BOOK_ID_ARG={$BOOK_ID_ARG}"
- /**
- * Helper to create the path. If bookId is null, it just won't be appended.
- */
fun createRoute(pageId: String, bookId: String? = null): String {
return "$route/$pageId" + if (bookId != null) "?$BOOK_ID_ARG=$bookId" else ""
}
@@ -151,7 +150,7 @@ fun EditorView(
}
val editorControlTower = remember {
- EditorControlTower(scope, page, history, editorState).apply { registerObservers() }
+ EditorControlTower(scope, page, history, editorState, context, appRepository).apply { registerObservers() }
}
// Collect UI Events from ViewModel (navigation and snackbars)
@@ -256,6 +255,20 @@ fun EditorView(
appRepository.bookRepository
)
page.disposeOldPage()
+
+ // Trigger sync on note close if enabled
+ val settings = GlobalAppSettings.current
+ if (settings.syncSettings.syncEnabled && settings.syncSettings.syncOnNoteClose && bookId != null) {
+ // Use a new coroutine scope since the composition scope is being disposed
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ try {
+ SyncLogger.i("EditorSync", "Auto-syncing on editor close")
+ SyncEngine(context).syncNotebook(bookId)
+ } catch (e: Exception) {
+ SyncLogger.e("EditorSync", "Auto-sync failed: ${e.message}")
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/ethran/notable/editor/PageView.kt b/app/src/main/java/com/ethran/notable/editor/PageView.kt
index dfa36343..938387b1 100644
--- a/app/src/main/java/com/ethran/notable/editor/PageView.kt
+++ b/app/src/main/java/com/ethran/notable/editor/PageView.kt
@@ -365,23 +365,32 @@ class PageView(
try {
dbStrokes.create(strokes)
} catch (_: SQLiteConstraintException) {
- // There were some rare bugs when strokes weren't unique when inserting from history
- // I'm not sure if it's still a problem, let's just show the message
logAndShowError(
"saveStrokesToPersistLayer",
"Attempted to create strokes that already exist"
)
dbStrokes.update(strokes)
}
+ updateParentNotebookTimestamp()
}
}
private fun saveImagesToPersistLayer(image: List) {
coroutineScope.launch(Dispatchers.IO) {
dbImages.create(image)
+ updateParentNotebookTimestamp()
}
}
+ /**
+ * Update the parent notebook's updatedAt timestamp so sync knows it has changes.
+ */
+ private suspend fun updateParentNotebookTimestamp() {
+ val notebookId = pageFromDb?.notebookId ?: return
+ val notebook = appRepository.bookRepository.getById(notebookId) ?: return
+ appRepository.bookRepository.update(notebook)
+ }
+
fun addImage(imageToAdd: Image) {
images += listOf(imageToAdd)
@@ -423,12 +432,14 @@ class PageView(
private fun removeStrokesFromPersistLayer(strokeIds: List) {
coroutineScope.launch(Dispatchers.IO) {
appRepository.strokeRepository.deleteAll(strokeIds)
+ updateParentNotebookTimestamp()
}
}
private fun removeImagesFromPersistLayer(imageIds: List) {
coroutineScope.launch(Dispatchers.IO) {
appRepository.imageRepository.deleteAll(imageIds)
+ updateParentNotebookTimestamp()
}
}
diff --git a/app/src/main/java/com/ethran/notable/io/share.kt b/app/src/main/java/com/ethran/notable/io/share.kt
index b72d0054..e678b6a1 100644
--- a/app/src/main/java/com/ethran/notable/io/share.kt
+++ b/app/src/main/java/com/ethran/notable/io/share.kt
@@ -33,7 +33,7 @@ fun shareBitmap(context: Context, bitmap: Bitmap) {
bmpWithBackground.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
} catch (e: IOException) {
- e.printStackTrace()
+ log.e("Failed to save shared image: ${e.message}", e)
return
}
@@ -96,7 +96,7 @@ private fun saveBitmapToCache(context: Context, bitmap: Bitmap): Uri? {
)
stream.close()
} catch (e: IOException) {
- e.printStackTrace()
+ log.e("Failed to save PDF preview image: ${e.message}", e)
}
val bitmapFile = File(cachePath, "share.png")
diff --git a/app/src/main/java/com/ethran/notable/sync/ConnectivityChecker.kt b/app/src/main/java/com/ethran/notable/sync/ConnectivityChecker.kt
new file mode 100644
index 00000000..c9c6151b
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/ConnectivityChecker.kt
@@ -0,0 +1,35 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+
+/**
+ * Checks network connectivity status for sync operations.
+ */
+class ConnectivityChecker(private val context: Context) {
+
+ private val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ /**
+ * Check if network is available and connected.
+ * @return true if internet connection is available
+ */
+ fun isNetworkAvailable(): Boolean {
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ /**
+ * Check if on an unmetered connection (WiFi or ethernet, not metered mobile data).
+ * Mirrors WorkManager's NetworkType.UNMETERED so the in-process check stays consistent
+ * with the WorkManager constraint used in SyncScheduler.
+ */
+ fun isUnmeteredConnected(): Boolean {
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/CredentialManager.kt b/app/src/main/java/com/ethran/notable/sync/CredentialManager.kt
new file mode 100644
index 00000000..250486a3
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/CredentialManager.kt
@@ -0,0 +1,68 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+/**
+ * Manages secure storage of WebDAV credentials using EncryptedSharedPreferences.
+ * Credentials are stored separately from the KV database to ensure they're encrypted at rest.
+ */
+class CredentialManager(private val context: Context) {
+
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val encryptedPrefs = EncryptedSharedPreferences.create(
+ context,
+ PREFS_FILE_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ /**
+ * Save WebDAV credentials securely.
+ * @param username WebDAV username
+ * @param password WebDAV password
+ */
+ fun saveCredentials(username: String, password: String) {
+ encryptedPrefs.edit()
+ .putString(KEY_USERNAME, username)
+ .putString(KEY_PASSWORD, password)
+ .apply()
+ }
+
+ /**
+ * Retrieve WebDAV credentials.
+ * @return Pair of (username, password) or null if not set
+ */
+ fun getCredentials(): Pair? {
+ val username = encryptedPrefs.getString(KEY_USERNAME, null) ?: return null
+ val password = encryptedPrefs.getString(KEY_PASSWORD, null) ?: return null
+ return username to password
+ }
+
+ /**
+ * Clear stored credentials (e.g., on logout or reset).
+ */
+ fun clearCredentials() {
+ encryptedPrefs.edit().clear().apply()
+ }
+
+ /**
+ * Check if credentials are stored.
+ * @return true if both username and password are present
+ */
+ fun hasCredentials(): Boolean {
+ return encryptedPrefs.contains(KEY_USERNAME) &&
+ encryptedPrefs.contains(KEY_PASSWORD)
+ }
+
+ companion object {
+ private const val PREFS_FILE_NAME = "notable_sync_credentials"
+ private const val KEY_USERNAME = "username"
+ private const val KEY_PASSWORD = "password"
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/DeletionsSerializer.kt b/app/src/main/java/com/ethran/notable/sync/DeletionsSerializer.kt
new file mode 100644
index 00000000..da9b0e8c
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/DeletionsSerializer.kt
@@ -0,0 +1,50 @@
+package com.ethran.notable.sync
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.encodeToString
+
+/**
+ * Legacy deletion tracking format used by the old deletions.json approach.
+ *
+ * This class is only kept for migration purposes: [SyncEngine.migrateDeletionsJsonToTombstones]
+ * reads any existing deletions.json file on the server, creates individual tombstone files for
+ * each entry, and then deletes deletions.json. After migration this class is no longer written.
+ *
+ * New deletions are tracked via zero-byte tombstone files at tombstones/{notebookId}.
+ */
+@Serializable
+data class DeletionsData(
+ // Map of notebook ID to ISO8601 deletion timestamp
+ val deletedNotebooks: Map = emptyMap(),
+
+ // Older legacy field (pre-timestamp format) — read during migration only
+ val deletedNotebookIds: Set = emptySet()
+) {
+ /**
+ * Returns all deleted notebook IDs regardless of format.
+ */
+ fun getAllDeletedIds(): Set {
+ return deletedNotebooks.keys + deletedNotebookIds
+ }
+}
+
+object DeletionsSerializer {
+ private val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ }
+
+ fun serialize(deletions: DeletionsData): String {
+ return json.encodeToString(deletions)
+ }
+
+ fun deserialize(jsonString: String): DeletionsData {
+ return try {
+ json.decodeFromString(jsonString)
+ } catch (e: Exception) {
+ // If parsing fails, return empty
+ DeletionsData()
+ }
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/FolderSerializer.kt b/app/src/main/java/com/ethran/notable/sync/FolderSerializer.kt
new file mode 100644
index 00000000..50ef13f2
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/FolderSerializer.kt
@@ -0,0 +1,120 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.db.Folder
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Serializer for folder hierarchy to/from JSON format for WebDAV sync.
+ */
+object FolderSerializer {
+
+ private val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ }
+
+ private val iso8601Format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ /**
+ * Serialize list of folders to JSON string (folders.json format).
+ * @param folders List of Folder entities from database
+ * @return JSON string representation
+ */
+ fun serializeFolders(folders: List): String {
+ val folderDtos = folders.map { folder ->
+ FolderDto(
+ id = folder.id,
+ title = folder.title,
+ parentFolderId = folder.parentFolderId,
+ createdAt = iso8601Format.format(folder.createdAt),
+ updatedAt = iso8601Format.format(folder.updatedAt)
+ )
+ }
+
+ val foldersJson = FoldersJson(
+ version = 1,
+ folders = folderDtos,
+ serverTimestamp = iso8601Format.format(Date())
+ )
+
+ return json.encodeToString(foldersJson)
+ }
+
+ /**
+ * Deserialize JSON string to list of Folder entities.
+ * @param jsonString JSON string in folders.json format
+ * @return List of Folder entities
+ */
+ fun deserializeFolders(jsonString: String): List {
+ val foldersJson = json.decodeFromString(jsonString)
+
+ return foldersJson.folders.map { dto ->
+ Folder(
+ id = dto.id,
+ title = dto.title,
+ parentFolderId = dto.parentFolderId,
+ createdAt = parseIso8601(dto.createdAt),
+ updatedAt = parseIso8601(dto.updatedAt)
+ )
+ }
+ }
+
+ /**
+ * Get server timestamp from folders.json.
+ * @param jsonString JSON string in folders.json format
+ * @return Server timestamp as Date, or null if parsing fails
+ */
+ fun getServerTimestamp(jsonString: String): Date? {
+ return try {
+ val foldersJson = json.decodeFromString(jsonString)
+ parseIso8601(foldersJson.serverTimestamp)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Parse ISO 8601 date string to Date object.
+ */
+ private fun parseIso8601(dateString: String): Date {
+ return try {
+ iso8601Format.parse(dateString) ?: Date()
+ } catch (e: Exception) {
+ Date()
+ }
+ }
+
+ /**
+ * Data transfer object for folder in JSON format.
+ *
+ * This deliberately duplicates the fields of the Folder Room entity. The Room entity uses
+ * Java Date for timestamps and carries Room annotations; FolderDto uses plain Strings so
+ * it can be handled by kotlinx.serialization without a custom serializer.
+ */
+ @Serializable
+ private data class FolderDto(
+ val id: String,
+ val title: String,
+ val parentFolderId: String? = null,
+ val createdAt: String,
+ val updatedAt: String
+ )
+
+ /**
+ * Root JSON structure for folders.json file.
+ */
+ @Serializable
+ private data class FoldersJson(
+ val version: Int,
+ val folders: List,
+ val serverTimestamp: String
+ )
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/NotebookSerializer.kt b/app/src/main/java/com/ethran/notable/sync/NotebookSerializer.kt
new file mode 100644
index 00000000..b5f2659d
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/NotebookSerializer.kt
@@ -0,0 +1,315 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import android.util.Base64
+import com.ethran.notable.data.db.Image
+import com.ethran.notable.data.db.Notebook
+import com.ethran.notable.data.db.Page
+import com.ethran.notable.data.db.Stroke
+import com.ethran.notable.data.db.StrokePoint
+import com.ethran.notable.data.db.encodeStrokePoints
+import com.ethran.notable.data.db.decodeStrokePoints
+import com.ethran.notable.editor.utils.Pen
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Serializer for notebooks, pages, strokes, and images to/from JSON format for WebDAV sync.
+ */
+class NotebookSerializer(private val context: Context) {
+
+ private val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ }
+
+ private val iso8601Format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ /**
+ * Serialize notebook metadata to manifest.json format.
+ * @param notebook Notebook entity from database
+ * @return JSON string for manifest.json
+ */
+ fun serializeManifest(notebook: Notebook): String {
+ val manifestDto = NotebookManifestDto(
+ version = 1,
+ notebookId = notebook.id,
+ title = notebook.title,
+ pageIds = notebook.pageIds,
+ openPageId = notebook.openPageId,
+ parentFolderId = notebook.parentFolderId,
+ defaultBackground = notebook.defaultBackground,
+ defaultBackgroundType = notebook.defaultBackgroundType,
+ linkedExternalUri = notebook.linkedExternalUri,
+ createdAt = iso8601Format.format(notebook.createdAt),
+ updatedAt = iso8601Format.format(notebook.updatedAt),
+ serverTimestamp = iso8601Format.format(Date())
+ )
+
+ return json.encodeToString(manifestDto)
+ }
+
+ /**
+ * Deserialize manifest.json to Notebook entity.
+ * @param jsonString JSON string in manifest.json format
+ * @return Notebook entity
+ */
+ fun deserializeManifest(jsonString: String): Notebook {
+ val manifestDto = json.decodeFromString(jsonString)
+
+ return Notebook(
+ id = manifestDto.notebookId,
+ title = manifestDto.title,
+ openPageId = manifestDto.openPageId,
+ pageIds = manifestDto.pageIds,
+ parentFolderId = manifestDto.parentFolderId,
+ defaultBackground = manifestDto.defaultBackground,
+ defaultBackgroundType = manifestDto.defaultBackgroundType,
+ linkedExternalUri = manifestDto.linkedExternalUri,
+ createdAt = parseIso8601(manifestDto.createdAt),
+ updatedAt = parseIso8601(manifestDto.updatedAt)
+ )
+ }
+
+ /**
+ * Serialize a page with its strokes and images to JSON format.
+ * Stroke points are embedded as base64-encoded SB1 binary format.
+ * @param page Page entity
+ * @param strokes List of Stroke entities for this page
+ * @param images List of Image entities for this page
+ * @return JSON string for {page-id}.json
+ */
+ fun serializePage(page: Page, strokes: List, images: List): String {
+ // Serialize strokes with embedded base64-encoded SB1 binary points
+ val strokeDtos = strokes.map { stroke ->
+ // Encode stroke points using SB1 binary format, then base64 encode
+ val binaryData = encodeStrokePoints(stroke.points)
+ val base64Data = Base64.encodeToString(binaryData, Base64.NO_WRAP)
+
+ StrokeDto(
+ id = stroke.id,
+ size = stroke.size,
+ pen = stroke.pen.name,
+ color = stroke.color,
+ maxPressure = stroke.maxPressure,
+ top = stroke.top,
+ bottom = stroke.bottom,
+ left = stroke.left,
+ right = stroke.right,
+ pointsData = base64Data,
+ createdAt = iso8601Format.format(stroke.createdAt),
+ updatedAt = iso8601Format.format(stroke.updatedAt)
+ )
+ }
+
+ val imageDtos = images.map { image ->
+ ImageDto(
+ id = image.id,
+ x = image.x,
+ y = image.y,
+ width = image.width,
+ height = image.height,
+ uri = convertToRelativeUri(image.uri), // Convert to relative path
+ createdAt = iso8601Format.format(image.createdAt),
+ updatedAt = iso8601Format.format(image.updatedAt)
+ )
+ }
+
+ val pageDto = PageDto(
+ version = 1,
+ id = page.id,
+ notebookId = page.notebookId,
+ background = page.background,
+ backgroundType = page.backgroundType,
+ parentFolderId = page.parentFolderId,
+ scroll = page.scroll,
+ createdAt = iso8601Format.format(page.createdAt),
+ updatedAt = iso8601Format.format(page.updatedAt),
+ strokes = strokeDtos,
+ images = imageDtos
+ )
+
+ return json.encodeToString(pageDto)
+ }
+
+ /**
+ * Deserialize page JSON with embedded base64-encoded SB1 binary stroke data.
+ * @param jsonString JSON string in page format
+ * @return Triple of (Page, List, List)
+ */
+ fun deserializePage(jsonString: String): Triple, List> {
+ val pageDto = json.decodeFromString(jsonString)
+
+ val page = Page(
+ id = pageDto.id,
+ notebookId = pageDto.notebookId,
+ background = pageDto.background,
+ backgroundType = pageDto.backgroundType,
+ parentFolderId = pageDto.parentFolderId,
+ scroll = pageDto.scroll,
+ createdAt = parseIso8601(pageDto.createdAt),
+ updatedAt = parseIso8601(pageDto.updatedAt)
+ )
+
+ val strokes = pageDto.strokes.map { strokeDto ->
+ // Decode base64 to binary, then decode SB1 binary format to stroke points
+ val binaryData = Base64.decode(strokeDto.pointsData, Base64.NO_WRAP)
+ val points = decodeStrokePoints(binaryData)
+
+ Stroke(
+ id = strokeDto.id,
+ size = strokeDto.size,
+ pen = Pen.valueOf(strokeDto.pen),
+ color = strokeDto.color,
+ maxPressure = strokeDto.maxPressure,
+ top = strokeDto.top,
+ bottom = strokeDto.bottom,
+ left = strokeDto.left,
+ right = strokeDto.right,
+ points = points,
+ pageId = pageDto.id,
+ createdAt = parseIso8601(strokeDto.createdAt),
+ updatedAt = parseIso8601(strokeDto.updatedAt)
+ )
+ }
+
+ val images = pageDto.images.map { imageDto ->
+ Image(
+ id = imageDto.id,
+ x = imageDto.x,
+ y = imageDto.y,
+ width = imageDto.width,
+ height = imageDto.height,
+ uri = imageDto.uri, // Will be converted to absolute path when restored
+ pageId = pageDto.id,
+ createdAt = parseIso8601(imageDto.createdAt),
+ updatedAt = parseIso8601(imageDto.updatedAt)
+ )
+ }
+
+ return Triple(page, strokes, images)
+ }
+
+ /**
+ * Convert absolute file URI to relative path for WebDAV storage.
+ * Example: /storage/emulated/0/Documents/notabledb/images/abc123.jpg -> images/abc123.jpg
+ */
+ private fun convertToRelativeUri(absoluteUri: String?): String? {
+ if (absoluteUri == null) return null
+
+ // Extract just the filename and parent directory
+ val file = File(absoluteUri)
+ val parentDir = file.parentFile?.name ?: ""
+ val filename = file.name
+
+ return if (parentDir.isNotEmpty()) {
+ "$parentDir/$filename"
+ } else {
+ filename
+ }
+ }
+
+ /**
+ * Parse ISO 8601 date string to Date object.
+ */
+ private fun parseIso8601(dateString: String): Date {
+ return try {
+ iso8601Format.parse(dateString) ?: Date()
+ } catch (e: Exception) {
+ Date()
+ }
+ }
+
+ /**
+ * Get updated timestamp from manifest JSON.
+ */
+ fun getManifestUpdatedAt(jsonString: String): Date? {
+ return try {
+ val manifestDto = json.decodeFromString(jsonString)
+ parseIso8601(manifestDto.updatedAt)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Get updated timestamp from page JSON.
+ */
+ fun getPageUpdatedAt(jsonString: String): Date? {
+ return try {
+ val pageDto = json.decodeFromString(jsonString)
+ parseIso8601(pageDto.updatedAt)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ // ===== Data Transfer Objects =====
+
+ @Serializable
+ private data class NotebookManifestDto(
+ val version: Int,
+ val notebookId: String,
+ val title: String,
+ val pageIds: List,
+ val openPageId: String?,
+ val parentFolderId: String?,
+ val defaultBackground: String,
+ val defaultBackgroundType: String,
+ val linkedExternalUri: String?,
+ val createdAt: String,
+ val updatedAt: String,
+ val serverTimestamp: String
+ )
+
+ @Serializable
+ private data class PageDto(
+ val version: Int,
+ val id: String,
+ val notebookId: String?,
+ val background: String,
+ val backgroundType: String,
+ val parentFolderId: String?,
+ val scroll: Int,
+ val createdAt: String,
+ val updatedAt: String,
+ val strokes: List,
+ val images: List
+ )
+
+ @Serializable
+ private data class StrokeDto(
+ val id: String,
+ val size: Float,
+ val pen: String,
+ val color: Int,
+ val maxPressure: Int,
+ val top: Float,
+ val bottom: Float,
+ val left: Float,
+ val right: Float,
+ val pointsData: String, // Base64-encoded SB1 binary format
+ val createdAt: String,
+ val updatedAt: String
+ )
+
+ @Serializable
+ private data class ImageDto(
+ val id: String,
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int,
+ val uri: String?, // Nullable — images can be uploaded before they have a local URI
+ val createdAt: String,
+ val updatedAt: String
+ )
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncEngine.kt b/app/src/main/java/com/ethran/notable/sync/SyncEngine.kt
new file mode 100644
index 00000000..e758c546
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncEngine.kt
@@ -0,0 +1,1109 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import com.ethran.notable.APP_SETTINGS_KEY
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.data.db.Folder
+import com.ethran.notable.data.db.KvProxy
+import com.ethran.notable.data.db.Notebook
+import com.ethran.notable.data.db.Page
+import com.ethran.notable.data.datastore.AppSettings
+import com.ethran.notable.data.ensureBackgroundsFolder
+import com.ethran.notable.data.ensureImagesFolder
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+
+// Alias for cleaner code
+private val SLog = SyncLogger
+
+/**
+ * Core sync engine orchestrating WebDAV synchronization.
+ * Handles bidirectional sync of folders, notebooks, pages, and files.
+ */
+class SyncEngine(private val context: Context) {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface SyncEngineEntryPoint {
+ fun appRepository(): AppRepository
+ fun kvProxy(): KvProxy
+ }
+
+ private val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext, SyncEngineEntryPoint::class.java
+ )
+ private val appRepository = entryPoint.appRepository()
+ private val kvProxy = entryPoint.kvProxy()
+ private val credentialManager = CredentialManager(context)
+ private val folderSerializer = FolderSerializer
+ private val notebookSerializer = NotebookSerializer(context)
+
+ /**
+ * Sync all notebooks and folders with the WebDAV server.
+ * @return SyncResult indicating success or failure
+ */
+ suspend fun syncAllNotebooks(): SyncResult = withContext(Dispatchers.IO) {
+ if (!syncMutex.tryLock()) {
+ SLog.w(TAG, "Sync already in progress, skipping")
+ return@withContext SyncResult.Failure(SyncError.SYNC_IN_PROGRESS)
+ }
+
+ val startTime = System.currentTimeMillis()
+ var notebooksSynced: Int
+ var notebooksDownloaded: Int
+ var notebooksDeleted: Int
+
+ return@withContext try {
+ SLog.i(TAG, "Starting full sync...")
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.INITIALIZING,
+ progress = PROGRESS_INITIALIZING,
+ details = "Initializing sync..."
+ ))
+
+ val initResult = initializeSyncClient()
+ if (initResult == null) {
+ updateState(SyncState.Error(
+ error = SyncError.CONFIG_ERROR,
+ step = SyncStep.INITIALIZING,
+ canRetry = false
+ ))
+ return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+ }
+ val (settings, webdavClient) = initResult
+
+ if (settings.syncSettings.wifiOnly && !ConnectivityChecker(context).isUnmeteredConnected()) {
+ SLog.i(TAG, "WiFi-only sync enabled but not on WiFi, skipping")
+ updateState(SyncState.Error(
+ error = SyncError.WIFI_REQUIRED,
+ step = SyncStep.INITIALIZING,
+ canRetry = false
+ ))
+ return@withContext SyncResult.Failure(SyncError.WIFI_REQUIRED)
+ }
+
+ val skewMs = checkClockSkew(webdavClient)
+ if (skewMs != null && kotlin.math.abs(skewMs) > CLOCK_SKEW_THRESHOLD_MS) {
+ val skewSec = skewMs / 1000
+ SLog.w(TAG, "Clock skew too large: ${skewSec}s (threshold: ${CLOCK_SKEW_THRESHOLD_MS / 1000}s)")
+ updateState(SyncState.Error(
+ error = SyncError.CLOCK_SKEW,
+ step = SyncStep.INITIALIZING,
+ canRetry = false
+ ))
+ return@withContext SyncResult.Failure(SyncError.CLOCK_SKEW)
+ }
+
+ ensureServerDirectories(webdavClient)
+
+ // 1. Sync folders first (they're referenced by notebooks)
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.SYNCING_FOLDERS,
+ progress = PROGRESS_SYNCING_FOLDERS,
+ details = "Syncing folders..."
+ ))
+ syncFolders(webdavClient)
+
+ // 2. Apply remote deletions (delete local notebooks that were deleted on other devices)
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.APPLYING_DELETIONS,
+ progress = PROGRESS_APPLYING_DELETIONS,
+ details = "Applying remote deletions..."
+ ))
+ val tombstonedIds = applyRemoteDeletions(webdavClient)
+
+ // 3. Sync existing local notebooks and capture pre-download snapshot
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.SYNCING_NOTEBOOKS,
+ progress = PROGRESS_SYNCING_NOTEBOOKS,
+ details = "Syncing local notebooks..."
+ ))
+ val preDownloadNotebookIds = syncExistingNotebooks()
+ notebooksSynced = preDownloadNotebookIds.size
+
+ // 4. Discover and download new notebooks from server
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.DOWNLOADING_NEW,
+ progress = PROGRESS_DOWNLOADING_NEW,
+ details = "Downloading new notebooks..."
+ ))
+ val newCount = downloadNewNotebooks(webdavClient, tombstonedIds, settings, preDownloadNotebookIds)
+ notebooksDownloaded = newCount
+
+ // 5. Detect local deletions and upload tombstones to server
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.UPLOADING_DELETIONS,
+ progress = PROGRESS_UPLOADING_DELETIONS,
+ details = "Uploading deletions..."
+ ))
+ val deletedCount = detectAndUploadLocalDeletions(webdavClient, settings, preDownloadNotebookIds)
+ notebooksDeleted = deletedCount
+
+ // 6. Update synced notebook IDs for next sync
+ updateState(SyncState.Syncing(
+ currentStep = SyncStep.FINALIZING,
+ progress = PROGRESS_FINALIZING,
+ details = "Finalizing..."
+ ))
+ updateSyncedNotebookIds(settings)
+
+ val duration = System.currentTimeMillis() - startTime
+ val summary = SyncSummary(
+ notebooksSynced = notebooksSynced,
+ notebooksDownloaded = notebooksDownloaded,
+ notebooksDeleted = notebooksDeleted,
+ duration = duration
+ )
+
+ SLog.i(TAG, "✓ Full sync completed in ${duration}ms")
+ updateState(SyncState.Success(summary))
+
+ delay(SUCCESS_STATE_AUTO_RESET_MS)
+ if (syncState.value is SyncState.Success) {
+ updateState(SyncState.Idle)
+ }
+
+ SyncResult.Success
+ } catch (e: IOException) {
+ SLog.e(TAG, "Network error during sync: ${e.message}")
+ val currentStep = (syncState.value as? SyncState.Syncing)?.currentStep ?: SyncStep.INITIALIZING
+ updateState(SyncState.Error(
+ error = SyncError.NETWORK_ERROR,
+ step = currentStep,
+ canRetry = true
+ ))
+ SyncResult.Failure(SyncError.NETWORK_ERROR)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Unexpected error during sync: ${e.message}\n${e.stackTraceToString()}")
+ val currentStep = (syncState.value as? SyncState.Syncing)?.currentStep ?: SyncStep.INITIALIZING
+ updateState(SyncState.Error(
+ error = SyncError.UNKNOWN_ERROR,
+ step = currentStep,
+ canRetry = false
+ ))
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ } finally {
+ syncMutex.unlock()
+ }
+ }
+
+ /**
+ * Sync a single notebook with the WebDAV server.
+ * Called on note open/close. If a full sync is already running, this is silently skipped
+ * to avoid concurrent WebDAV operations. A proper per-notebook mutex would be more correct
+ * but is overkill for the single-user use case.
+ * @param notebookId Notebook ID to sync
+ * @return SyncResult indicating success or failure
+ */
+ suspend fun syncNotebook(notebookId: String): SyncResult = withContext(Dispatchers.IO) {
+ // Skip if a full sync is already holding the mutex
+ if (syncMutex.isLocked) {
+ SLog.i(TAG, "Full sync in progress, skipping per-notebook sync for $notebookId")
+ return@withContext SyncResult.Success
+ }
+ return@withContext syncNotebookImpl(notebookId)
+ }
+
+ /**
+ * Internal notebook sync — does not check the mutex. Called from both
+ * the public [syncNotebook] entry point and [syncExistingNotebooks] (which
+ * already runs inside the full-sync mutex context).
+ */
+ private suspend fun syncNotebookImpl(notebookId: String): SyncResult {
+ return try {
+ SLog.i(TAG, "Syncing notebook: $notebookId")
+
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ ?: return SyncResult.Failure(SyncError.CONFIG_ERROR)
+
+ if (!settings.syncSettings.syncEnabled) {
+ return SyncResult.Success
+ }
+
+ if (settings.syncSettings.wifiOnly && !ConnectivityChecker(context).isUnmeteredConnected()) {
+ SLog.i(TAG, "WiFi-only sync enabled but not on WiFi, skipping notebook sync")
+ return SyncResult.Success
+ }
+
+ val credentials = credentialManager.getCredentials()
+ ?: return SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient = WebDAVClient(
+ settings.syncSettings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ val skewMs = checkClockSkew(webdavClient)
+ if (skewMs != null && kotlin.math.abs(skewMs) > CLOCK_SKEW_THRESHOLD_MS) {
+ val skewSec = skewMs / 1000
+ SLog.w(TAG, "Clock skew too large for single-notebook sync: ${skewSec}s")
+ return SyncResult.Failure(SyncError.CLOCK_SKEW)
+ }
+
+ val localNotebook = appRepository.bookRepository.getById(notebookId)
+ ?: return SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+
+ val remotePath = SyncPaths.manifestFile(notebookId)
+ val remoteExists = webdavClient.exists(remotePath)
+
+ SLog.i(TAG, "Checking: ${localNotebook.title}")
+
+ if (remoteExists) {
+ val remoteManifestJson = webdavClient.getFile(remotePath).decodeToString()
+ val remoteUpdatedAt = notebookSerializer.getManifestUpdatedAt(remoteManifestJson)
+
+ val diffMs = remoteUpdatedAt?.let { localNotebook.updatedAt.time - it.time } ?: Long.MAX_VALUE
+ SLog.i(TAG, "Remote: $remoteUpdatedAt (${remoteUpdatedAt?.time}ms)")
+ SLog.i(TAG, "Local: ${localNotebook.updatedAt} (${localNotebook.updatedAt.time}ms)")
+ SLog.i(TAG, "Difference: ${diffMs}ms")
+
+ when {
+ remoteUpdatedAt == null -> {
+ SLog.i(TAG, "↑ No remote timestamp, uploading ${localNotebook.title}")
+ uploadNotebook(localNotebook, webdavClient)
+ }
+ diffMs < -TIMESTAMP_TOLERANCE_MS -> {
+ SLog.i(TAG, "↓ Remote newer, downloading ${localNotebook.title}")
+ downloadNotebook(notebookId, webdavClient)
+ }
+ diffMs > TIMESTAMP_TOLERANCE_MS -> {
+ SLog.i(TAG, "↑ Local newer, uploading ${localNotebook.title}")
+ uploadNotebook(localNotebook, webdavClient)
+ }
+ else -> {
+ SLog.i(TAG, "= No changes (within tolerance), skipping ${localNotebook.title}")
+ }
+ }
+ } else {
+ SLog.i(TAG, "↑ New on server, uploading ${localNotebook.title}")
+ uploadNotebook(localNotebook, webdavClient)
+ }
+
+ SLog.i(TAG, "✓ Synced: ${localNotebook.title}")
+ SyncResult.Success
+ } catch (e: IOException) {
+ SLog.e(TAG, "Network error syncing notebook $notebookId: ${e.message}")
+ SyncResult.Failure(SyncError.NETWORK_ERROR)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Error syncing notebook $notebookId: ${e.message}\n${e.stackTraceToString()}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ /**
+ * Upload a notebook deletion to the server via a zero-byte tombstone file.
+ * More efficient than full sync when you just deleted one notebook.
+ *
+ * Tombstone approach: PUT an empty file at SyncPaths.tombstone(notebookId).
+ * This replaces the old deletions.json aggregation file, eliminating the
+ * write-write race condition where two devices could overwrite each other.
+ * The server's own lastModified on the tombstone provides the deletion timestamp
+ * for conflict resolution on other devices.
+ *
+ * TODO: When ETag support is added, tombstones can be deprecated in favour of
+ * detecting deletions via known-ETag + missing remote file (RFC 2518 §9.4).
+ *
+ * @param notebookId ID of the notebook that was deleted locally
+ * @return SyncResult indicating success or failure
+ */
+ suspend fun uploadDeletion(notebookId: String): SyncResult = withContext(Dispatchers.IO) {
+ return@withContext try {
+ SLog.i(TAG, "Uploading deletion for notebook: $notebookId")
+
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ ?: return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+
+ if (!settings.syncSettings.syncEnabled) {
+ return@withContext SyncResult.Success
+ }
+
+ // Respect wifiOnly - uploading a tombstone is still a network operation
+ if (settings.syncSettings.wifiOnly && !ConnectivityChecker(context).isUnmeteredConnected()) {
+ SLog.i(TAG, "WiFi-only sync enabled, deferring deletion upload to next WiFi sync")
+ return@withContext SyncResult.Success
+ }
+
+ val credentials = credentialManager.getCredentials()
+ ?: return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient = WebDAVClient(
+ settings.syncSettings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ // Delete notebook content from server
+ val notebookPath = SyncPaths.notebookDir(notebookId)
+ if (webdavClient.exists(notebookPath)) {
+ SLog.i(TAG, "✗ Deleting notebook content from server: $notebookId")
+ webdavClient.delete(notebookPath)
+ }
+
+ // Upload zero-byte tombstone file
+ webdavClient.putFile(SyncPaths.tombstone(notebookId), ByteArray(0), "application/octet-stream")
+ SLog.i(TAG, "✓ Tombstone uploaded for: $notebookId")
+
+ // Update syncedNotebookIds (remove the deleted notebook)
+ val updatedSyncedIds = settings.syncSettings.syncedNotebookIds - notebookId
+ kvProxy.setAppSettings(
+ settings.copy(
+ syncSettings = settings.syncSettings.copy(
+ syncedNotebookIds = updatedSyncedIds
+ )
+ )
+ )
+
+ SLog.i(TAG, "✓ Deletion uploaded successfully")
+ SyncResult.Success
+
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to upload deletion: ${e.message}\n${e.stackTraceToString()}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ /**
+ * Sync folder hierarchy with the WebDAV server.
+ *
+ * Note: folders.json is a shared aggregation file and has the same theoretical
+ * write-write race condition as the old deletions.json. This is documented but
+ * deferred until ETag (If-Match) support is added, at which point server-enforced
+ * atomic writes will make this robust.
+ */
+ private suspend fun syncFolders(webdavClient: WebDAVClient) {
+ SLog.i(TAG, "Syncing folders...")
+
+ try {
+ val localFolders = appRepository.folderRepository.getAll()
+
+ val remotePath = SyncPaths.foldersFile()
+ if (webdavClient.exists(remotePath)) {
+ val remoteFoldersJson = webdavClient.getFile(remotePath).decodeToString()
+ val remoteFolders = folderSerializer.deserializeFolders(remoteFoldersJson)
+
+ val folderMap = mutableMapOf()
+ remoteFolders.forEach { folderMap[it.id] = it }
+ localFolders.forEach { local ->
+ val remote = folderMap[local.id]
+ if (remote == null || local.updatedAt.after(remote.updatedAt)) {
+ folderMap[local.id] = local
+ }
+ }
+
+ val mergedFolders = folderMap.values.toList()
+ for (folder in mergedFolders) {
+ try {
+ appRepository.folderRepository.get(folder.id)
+ appRepository.folderRepository.update(folder)
+ } catch (_: Exception) {
+ appRepository.folderRepository.create(folder)
+ }
+ }
+
+ val updatedFoldersJson = folderSerializer.serializeFolders(mergedFolders)
+ webdavClient.putFile(remotePath, updatedFoldersJson.toByteArray(), "application/json")
+ SLog.i(TAG, "Synced ${mergedFolders.size} folders")
+ } else {
+ if (localFolders.isNotEmpty()) {
+ val foldersJson = folderSerializer.serializeFolders(localFolders)
+ webdavClient.putFile(remotePath, foldersJson.toByteArray(), "application/json")
+ SLog.i(TAG, "Uploaded ${localFolders.size} folders to server")
+ }
+ }
+ } catch (e: Exception) {
+ SLog.e(TAG, "Error syncing folders: ${e.message}\n${e.stackTraceToString()}")
+ throw e
+ }
+ }
+
+ /**
+ * Check for tombstone files on the server and delete any local notebooks that were
+ * deleted on other devices.
+ *
+ * Tombstones are zero-byte files at [SyncPaths.tombstone]. The server's lastModified
+ * on each tombstone provides the deletion timestamp for conflict resolution: if a local
+ * notebook was modified AFTER the tombstone was placed, it is treated as a resurrection
+ * and will be re-uploaded (overwriting the tombstone on the next full sync).
+ *
+ * @return Set of tombstoned notebook IDs (used to filter discovery in [downloadNewNotebooks])
+ */
+ private suspend fun applyRemoteDeletions(webdavClient: WebDAVClient): Set {
+ SLog.i(TAG, "Applying remote deletions...")
+
+ val tombstonesPath = SyncPaths.tombstonesDir()
+ if (!webdavClient.exists(tombstonesPath)) return emptySet()
+
+ val tombstones = webdavClient.listCollectionWithMetadata(tombstonesPath)
+ val tombstonedIds = tombstones.map { it.name }.toSet()
+
+ if (tombstones.isNotEmpty()) {
+ SLog.i(TAG, "Server has ${tombstones.size} tombstone(s)")
+ for (tombstone in tombstones) {
+ val notebookId = tombstone.name
+ val deletedAt = tombstone.lastModified
+
+ val localNotebook = appRepository.bookRepository.getById(notebookId) ?: continue
+
+ // Conflict resolution: local modified AFTER tombstone → resurrection
+ if (deletedAt != null && localNotebook.updatedAt.after(deletedAt)) {
+ SLog.i(TAG, "↻ Resurrecting '${localNotebook.title}' (modified after server deletion)")
+ continue
+ }
+
+ try {
+ SLog.i(TAG, "✗ Deleting locally (tombstone on server): ${localNotebook.title}")
+ appRepository.bookRepository.delete(notebookId)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to delete ${localNotebook.title}: ${e.message}")
+ }
+ }
+ }
+
+ // Prune stale tombstones. Safe to do after processing — the current device has
+ // already applied all deletions, so old tombstones are no longer needed by us.
+ // Devices that haven't synced in TOMBSTONE_MAX_AGE_DAYS need full reconciliation anyway.
+ val cutoff = java.util.Date(System.currentTimeMillis() - TOMBSTONE_MAX_AGE_DAYS * 86_400_000L)
+ val stale = tombstones.filter { it.lastModified != null && it.lastModified.before(cutoff) }
+ if (stale.isNotEmpty()) {
+ SLog.i(TAG, "Pruning ${stale.size} stale tombstone(s) older than $TOMBSTONE_MAX_AGE_DAYS days")
+ for (entry in stale) {
+ try {
+ webdavClient.delete(SyncPaths.tombstone(entry.name))
+ } catch (e: Exception) {
+ SLog.w(TAG, "Failed to prune tombstone ${entry.name}: ${e.message}")
+ }
+ }
+ }
+
+ return tombstonedIds
+ }
+
+ /**
+ * Detect notebooks that were deleted locally and upload tombstone files to server.
+ * @param preDownloadNotebookIds Snapshot of local notebook IDs BEFORE downloading new notebooks.
+ * @return Number of notebooks deleted
+ */
+ private fun detectAndUploadLocalDeletions(
+ webdavClient: WebDAVClient,
+ settings: AppSettings,
+ preDownloadNotebookIds: Set
+ ): Int {
+ SLog.i(TAG, "Detecting local deletions...")
+
+ val syncedNotebookIds = settings.syncSettings.syncedNotebookIds
+ val deletedLocally = syncedNotebookIds - preDownloadNotebookIds
+
+ if (deletedLocally.isNotEmpty()) {
+ SLog.i(TAG, "Detected ${deletedLocally.size} local deletion(s)")
+
+ for (notebookId in deletedLocally) {
+ try {
+ val notebookPath = SyncPaths.notebookDir(notebookId)
+ if (webdavClient.exists(notebookPath)) {
+ SLog.i(TAG, "✗ Deleting from server: $notebookId")
+ webdavClient.delete(notebookPath)
+ }
+
+ // Upload zero-byte tombstone
+ webdavClient.putFile(SyncPaths.tombstone(notebookId), ByteArray(0), "application/octet-stream")
+ SLog.i(TAG, "✓ Tombstone uploaded for: $notebookId")
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to process local deletion $notebookId: ${e.message}")
+ }
+ }
+ } else {
+ SLog.i(TAG, "No local deletions detected")
+ }
+
+ return deletedLocally.size
+ }
+
+ /**
+ * Upload a notebook to the WebDAV server.
+ */
+ private suspend fun uploadNotebook(notebook: Notebook, webdavClient: WebDAVClient) {
+ val notebookId = notebook.id
+ SLog.i(TAG, "Uploading: ${notebook.title} (${notebook.pageIds.size} pages)")
+
+ webdavClient.ensureParentDirectories(SyncPaths.pagesDir(notebookId) + "/")
+ webdavClient.createCollection(SyncPaths.imagesDir(notebookId))
+ webdavClient.createCollection(SyncPaths.backgroundsDir(notebookId))
+
+ val manifestJson = notebookSerializer.serializeManifest(notebook)
+ webdavClient.putFile(SyncPaths.manifestFile(notebookId), manifestJson.toByteArray(), "application/json")
+
+ val pages = appRepository.pageRepository.getByIds(notebook.pageIds)
+ for (page in pages) {
+ uploadPage(page, notebookId, webdavClient)
+ }
+
+ // If a tombstone exists for this notebook (resurrection case), remove it
+ val tombstonePath = SyncPaths.tombstone(notebookId)
+ if (webdavClient.exists(tombstonePath)) {
+ webdavClient.delete(tombstonePath)
+ SLog.i(TAG, "Removed stale tombstone for resurrected notebook: $notebookId")
+ }
+
+ SLog.i(TAG, "✓ Uploaded: ${notebook.title}")
+ }
+
+ /**
+ * Upload a single page with its strokes and images.
+ */
+ private suspend fun uploadPage(page: Page, notebookId: String, webdavClient: WebDAVClient) {
+ val pageWithStrokes = appRepository.pageRepository.getWithStrokeById(page.id)
+ val pageWithImages = appRepository.pageRepository.getWithImageById(page.id)
+
+ val pageJson = notebookSerializer.serializePage(
+ page,
+ pageWithStrokes.strokes,
+ pageWithImages.images
+ )
+
+ webdavClient.putFile(
+ SyncPaths.pageFile(notebookId, page.id),
+ pageJson.toByteArray(),
+ "application/json"
+ )
+
+ for (image in pageWithImages.images) {
+ if (!image.uri.isNullOrEmpty()) {
+ val localFile = File(image.uri)
+ if (localFile.exists()) {
+ val remotePath = SyncPaths.imageFile(notebookId, localFile.name)
+ if (!webdavClient.exists(remotePath)) {
+ webdavClient.putFile(remotePath, localFile, detectMimeType(localFile))
+ SLog.i(TAG, "Uploaded image: ${localFile.name}")
+ }
+ } else {
+ SLog.w(TAG, "Image file not found: ${image.uri}")
+ }
+ }
+ }
+
+ if (page.backgroundType != "native" && page.background != "blank") {
+ val bgFile = File(ensureBackgroundsFolder(), page.background)
+ if (bgFile.exists()) {
+ val remotePath = SyncPaths.backgroundFile(notebookId, bgFile.name)
+ if (!webdavClient.exists(remotePath)) {
+ webdavClient.putFile(remotePath, bgFile, detectMimeType(bgFile))
+ SLog.i(TAG, "Uploaded background: ${bgFile.name}")
+ }
+ }
+ }
+ }
+
+ /**
+ * Download a notebook from the WebDAV server.
+ */
+ private suspend fun downloadNotebook(notebookId: String, webdavClient: WebDAVClient) {
+ SLog.i(TAG, "Downloading notebook ID: $notebookId")
+
+ val manifestJson = webdavClient.getFile(SyncPaths.manifestFile(notebookId)).decodeToString()
+ val notebook = notebookSerializer.deserializeManifest(manifestJson)
+
+ SLog.i(TAG, "Found notebook: ${notebook.title} (${notebook.pageIds.size} pages)")
+
+ val existingNotebook = appRepository.bookRepository.getById(notebookId)
+ if (existingNotebook != null) {
+ appRepository.bookRepository.updatePreservingTimestamp(notebook)
+ } else {
+ appRepository.bookRepository.createEmpty(notebook)
+ }
+
+ for (pageId in notebook.pageIds) {
+ try {
+ downloadPage(pageId, notebookId, webdavClient)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to download page $pageId: ${e.message}")
+ }
+ }
+
+ SLog.i(TAG, "✓ Downloaded: ${notebook.title}")
+ }
+
+ /**
+ * Download a single page with its strokes and images.
+ */
+ private suspend fun downloadPage(pageId: String, notebookId: String, webdavClient: WebDAVClient) {
+ val pageJson = webdavClient.getFile(SyncPaths.pageFile(notebookId, pageId)).decodeToString()
+ val (page, strokes, images) = notebookSerializer.deserializePage(pageJson)
+
+ val updatedImages = images.map { image ->
+ if (!image.uri.isNullOrEmpty()) {
+ try {
+ val filename = extractFilename(image.uri)
+ val localFile = File(ensureImagesFolder(), filename)
+
+ if (!localFile.exists()) {
+ webdavClient.getFile(SyncPaths.imageFile(notebookId, filename), localFile)
+ SLog.i(TAG, "Downloaded image: $filename")
+ }
+
+ image.copy(uri = localFile.absolutePath)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to download image ${image.uri}: ${e.message}\n${e.stackTraceToString()}")
+ image
+ }
+ } else {
+ image
+ }
+ }
+
+ if (page.backgroundType != "native" && page.background != "blank") {
+ try {
+ val filename = page.background
+ val localFile = File(ensureBackgroundsFolder(), filename)
+
+ if (!localFile.exists()) {
+ webdavClient.getFile(SyncPaths.backgroundFile(notebookId, filename), localFile)
+ SLog.i(TAG, "Downloaded background: $filename")
+ }
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to download background ${page.background}: ${e.message}\n${e.stackTraceToString()}")
+ }
+ }
+
+ val existingPage = appRepository.pageRepository.getById(page.id)
+ if (existingPage != null) {
+ val existingStrokes = appRepository.pageRepository.getWithStrokeById(page.id).strokes
+ val existingImages = appRepository.pageRepository.getWithImageById(page.id).images
+
+ appRepository.strokeRepository.deleteAll(existingStrokes.map { it.id })
+ appRepository.imageRepository.deleteAll(existingImages.map { it.id })
+
+ appRepository.pageRepository.update(page)
+ } else {
+ appRepository.pageRepository.create(page)
+ }
+
+ appRepository.strokeRepository.create(strokes)
+ appRepository.imageRepository.create(updatedImages)
+ }
+
+ /**
+ * Force upload all local data to server (replaces server data).
+ * WARNING: This deletes all data on the server first!
+ */
+ suspend fun forceUploadAll(): SyncResult = withContext(Dispatchers.IO) {
+ return@withContext try {
+ SLog.i(TAG, "⚠ FORCE UPLOAD: Replacing server with local data")
+
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ ?: return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+
+ val credentials = credentialManager.getCredentials()
+ ?: return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient = WebDAVClient(
+ settings.syncSettings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ try {
+ if (webdavClient.exists(SyncPaths.notebooksDir())) {
+ val existingNotebooks = webdavClient.listCollection(SyncPaths.notebooksDir())
+ SLog.i(TAG, "Deleting ${existingNotebooks.size} existing notebooks from server")
+ for (notebookDir in existingNotebooks) {
+ try {
+ webdavClient.delete(SyncPaths.notebookDir(notebookDir))
+ } catch (e: Exception) {
+ SLog.w(TAG, "Failed to delete $notebookDir: ${e.message}")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ SLog.w(TAG, "Error cleaning server notebooks: ${e.message}")
+ }
+
+ if (!webdavClient.exists(SyncPaths.rootDir())) {
+ webdavClient.createCollection(SyncPaths.rootDir())
+ }
+ if (!webdavClient.exists(SyncPaths.notebooksDir())) {
+ webdavClient.createCollection(SyncPaths.notebooksDir())
+ }
+ if (!webdavClient.exists(SyncPaths.tombstonesDir())) {
+ webdavClient.createCollection(SyncPaths.tombstonesDir())
+ }
+
+ val folders = appRepository.folderRepository.getAll()
+ if (folders.isNotEmpty()) {
+ val foldersJson = folderSerializer.serializeFolders(folders)
+ webdavClient.putFile(SyncPaths.foldersFile(), foldersJson.toByteArray(), "application/json")
+ SLog.i(TAG, "Uploaded ${folders.size} folders")
+ }
+
+ val notebooks = appRepository.bookRepository.getAll()
+ SLog.i(TAG, "Uploading ${notebooks.size} local notebooks...")
+ for (notebook in notebooks) {
+ try {
+ uploadNotebook(notebook, webdavClient)
+ SLog.i(TAG, "✓ Uploaded: ${notebook.title}")
+ } catch (e: Exception) {
+ SLog.e(TAG, "✗ Failed to upload ${notebook.title}: ${e.message}")
+ }
+ }
+
+ SLog.i(TAG, "✓ FORCE UPLOAD complete: ${notebooks.size} notebooks")
+ SyncResult.Success
+ } catch (e: Exception) {
+ SLog.e(TAG, "Force upload failed: ${e.message}\n${e.stackTraceToString()}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ /**
+ * Force download all server data to local (replaces local data).
+ * WARNING: This deletes all local notebooks first!
+ */
+ suspend fun forceDownloadAll(): SyncResult = withContext(Dispatchers.IO) {
+ return@withContext try {
+ SLog.i(TAG, "⚠ FORCE DOWNLOAD: Replacing local with server data")
+
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ ?: return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+
+ val credentials = credentialManager.getCredentials()
+ ?: return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient = WebDAVClient(
+ settings.syncSettings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ val localFolders = appRepository.folderRepository.getAll()
+ for (folder in localFolders) {
+ appRepository.folderRepository.delete(folder.id)
+ }
+
+ val localNotebooks = appRepository.bookRepository.getAll()
+ for (notebook in localNotebooks) {
+ appRepository.bookRepository.delete(notebook.id)
+ }
+ SLog.i(TAG, "Deleted ${localFolders.size} folders and ${localNotebooks.size} local notebooks")
+
+ if (webdavClient.exists(SyncPaths.foldersFile())) {
+ val foldersJson = webdavClient.getFile(SyncPaths.foldersFile()).decodeToString()
+ val folders = folderSerializer.deserializeFolders(foldersJson)
+ for (folder in folders) {
+ appRepository.folderRepository.create(folder)
+ }
+ SLog.i(TAG, "Downloaded ${folders.size} folders from server")
+ }
+
+ if (webdavClient.exists(SyncPaths.notebooksDir())) {
+ val notebookDirs = webdavClient.listCollection(SyncPaths.notebooksDir())
+ SLog.i(TAG, "Found ${notebookDirs.size} notebook(s) on server")
+
+ for (notebookDir in notebookDirs) {
+ try {
+ val notebookId = notebookDir.trimEnd('/')
+ SLog.i(TAG, "Downloading notebook: $notebookId")
+ downloadNotebook(notebookId, webdavClient)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to download $notebookDir: ${e.message}\n${e.stackTraceToString()}")
+ }
+ }
+ } else {
+ SLog.w(TAG, "${SyncPaths.notebooksDir()} doesn't exist on server")
+ }
+
+ SLog.i(TAG, "✓ FORCE DOWNLOAD complete")
+ SyncResult.Success
+ } catch (e: Exception) {
+ SLog.e(TAG, "Force download failed: ${e.message}\n${e.stackTraceToString()}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ /**
+ * Extract filename from a URI or path.
+ */
+ private fun extractFilename(uri: String): String {
+ return uri.substringAfterLast('/')
+ }
+
+ /**
+ * Detect MIME type from file extension.
+ */
+ private fun detectMimeType(file: File): String {
+ return when (file.extension.lowercase()) {
+ "jpg", "jpeg" -> "image/jpeg"
+ "png" -> "image/png"
+ "pdf" -> "application/pdf"
+ else -> "application/octet-stream"
+ }
+ }
+
+ /**
+ * Check clock skew between this device and the WebDAV server.
+ * @return Skew in ms (deviceTime - serverTime), or null if server time unavailable
+ */
+ private fun checkClockSkew(webdavClient: WebDAVClient): Long? {
+ val serverTime = webdavClient.getServerTime() ?: return null
+ return System.currentTimeMillis() - serverTime
+ }
+
+ /**
+ * Initialize sync client by getting settings and credentials.
+ * @return Pair of (AppSettings, WebDAVClient) or null if initialization fails
+ */
+ private suspend fun initializeSyncClient(): Pair? {
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ ?: return null
+
+ if (!settings.syncSettings.syncEnabled) {
+ SLog.i(TAG, "Sync disabled in settings")
+ return null
+ }
+
+ val credentials = credentialManager.getCredentials()
+ ?: return null
+
+ val webdavClient = WebDAVClient(
+ settings.syncSettings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ return Pair(settings, webdavClient)
+ }
+
+ /**
+ * Ensure required server directory structure exists, and run one-time migration
+ * from the old deletions.json format to tombstone files.
+ */
+ private fun ensureServerDirectories(webdavClient: WebDAVClient) {
+ if (!webdavClient.exists(SyncPaths.rootDir())) {
+ webdavClient.createCollection(SyncPaths.rootDir())
+ }
+ if (!webdavClient.exists(SyncPaths.notebooksDir())) {
+ webdavClient.createCollection(SyncPaths.notebooksDir())
+ }
+ if (!webdavClient.exists(SyncPaths.tombstonesDir())) {
+ webdavClient.createCollection(SyncPaths.tombstonesDir())
+ }
+ migrateDeletionsJsonToTombstones(webdavClient)
+ }
+
+ /**
+ * One-time migration: convert old deletions.json entries to individual tombstone files,
+ * then delete the legacy file.
+ */
+ private fun migrateDeletionsJsonToTombstones(webdavClient: WebDAVClient) {
+ if (!webdavClient.exists(LEGACY_DELETIONS_FILE)) return
+
+ try {
+ val json = webdavClient.getFile(LEGACY_DELETIONS_FILE).decodeToString()
+ val data = DeletionsSerializer.deserialize(json)
+
+ for (notebookId in data.getAllDeletedIds()) {
+ val tombstonePath = SyncPaths.tombstone(notebookId)
+ if (!webdavClient.exists(tombstonePath)) {
+ webdavClient.putFile(tombstonePath, ByteArray(0), "application/octet-stream")
+ }
+ }
+
+ webdavClient.delete(LEGACY_DELETIONS_FILE)
+ SLog.i(TAG, "Migrated ${data.getAllDeletedIds().size} entries from deletions.json to tombstones")
+ } catch (e: Exception) {
+ SLog.w(TAG, "Failed to migrate deletions.json: ${e.message}")
+ }
+ }
+
+ /**
+ * Sync all existing local notebooks.
+ * @return Set of notebook IDs that existed before any new downloads
+ */
+ private suspend fun syncExistingNotebooks(): Set {
+ val localNotebooks = appRepository.bookRepository.getAll()
+ val preDownloadNotebookIds = localNotebooks.map { it.id }.toSet()
+ SLog.i(TAG, "Found ${localNotebooks.size} local notebooks")
+
+ for (notebook in localNotebooks) {
+ try {
+ syncNotebookImpl(notebook.id)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to sync ${notebook.title}: ${e.message}")
+ }
+ }
+
+ return preDownloadNotebookIds
+ }
+
+ /**
+ * Discover and download new notebooks from server that don't exist locally.
+ * @param tombstonedIds Notebook IDs that have tombstones — skip these, they were intentionally deleted
+ * @return Number of notebooks downloaded
+ */
+ private suspend fun downloadNewNotebooks(
+ webdavClient: WebDAVClient,
+ tombstonedIds: Set,
+ settings: AppSettings,
+ preDownloadNotebookIds: Set
+ ): Int {
+ SLog.i(TAG, "Checking server for new notebooks...")
+
+ if (!webdavClient.exists(SyncPaths.notebooksDir())) {
+ return 0
+ }
+
+ val serverNotebookDirs = webdavClient.listCollection(SyncPaths.notebooksDir())
+
+ val newNotebookIds = serverNotebookDirs
+ .map { it.trimEnd('/') }
+ .filter { it !in preDownloadNotebookIds }
+ .filter { it !in tombstonedIds }
+ .filter { it !in settings.syncSettings.syncedNotebookIds }
+
+ if (newNotebookIds.isNotEmpty()) {
+ SLog.i(TAG, "Found ${newNotebookIds.size} new notebook(s) on server")
+ for (notebookId in newNotebookIds) {
+ try {
+ SLog.i(TAG, "↓ Downloading new notebook from server: $notebookId")
+ downloadNotebook(notebookId, webdavClient)
+ } catch (e: Exception) {
+ SLog.e(TAG, "Failed to download $notebookId: ${e.message}")
+ }
+ }
+ } else {
+ SLog.i(TAG, "No new notebooks on server")
+ }
+
+ return newNotebookIds.size
+ }
+
+ /**
+ * Update the list of synced notebook IDs in settings.
+ */
+ private suspend fun updateSyncedNotebookIds(settings: AppSettings) {
+ val currentNotebookIds = appRepository.bookRepository.getAll().map { it.id }.toSet()
+ kvProxy.setAppSettings(
+ settings.copy(
+ syncSettings = settings.syncSettings.copy(
+ syncedNotebookIds = currentNotebookIds
+ )
+ )
+ )
+ }
+
+ companion object {
+ private const val TAG = "SyncEngine"
+
+ // Path to the legacy deletions.json file, used only for one-time migration
+ private const val LEGACY_DELETIONS_FILE = "/notable/deletions.json"
+
+ // Progress percentages for each sync step
+ private const val PROGRESS_INITIALIZING = 0.0f
+ private const val PROGRESS_SYNCING_FOLDERS = 0.1f
+ private const val PROGRESS_APPLYING_DELETIONS = 0.2f
+ private const val PROGRESS_SYNCING_NOTEBOOKS = 0.3f
+ private const val PROGRESS_DOWNLOADING_NEW = 0.6f
+ private const val PROGRESS_UPLOADING_DELETIONS = 0.8f
+ private const val PROGRESS_FINALIZING = 0.9f
+
+ // Timing constants
+ private const val SUCCESS_STATE_AUTO_RESET_MS = 3000L
+ private const val TIMESTAMP_TOLERANCE_MS = 1000L
+ private const val CLOCK_SKEW_THRESHOLD_MS = 30_000L
+
+ // Tombstones older than this are pruned at the end of applyRemoteDeletions().
+ // Any device that hasn't synced in this long will need full reconciliation anyway.
+ private const val TOMBSTONE_MAX_AGE_DAYS = 90L
+
+ // Shared state across all SyncEngine instances
+ private val _syncState = MutableStateFlow(SyncState.Idle)
+ val syncState: StateFlow = _syncState.asStateFlow()
+
+ // Mutex to prevent concurrent full syncs
+ private val syncMutex = Mutex()
+
+ /**
+ * Update the sync state (internal use only).
+ */
+ internal fun updateState(state: SyncState) {
+ _syncState.value = state
+ }
+ }
+}
+
+/**
+ * Result of a sync operation.
+ */
+sealed class SyncResult {
+ object Success : SyncResult()
+ data class Failure(val error: SyncError) : SyncResult()
+}
+
+/**
+ * Types of sync errors.
+ */
+enum class SyncError {
+ NETWORK_ERROR,
+ AUTH_ERROR,
+ CONFIG_ERROR,
+ CLOCK_SKEW,
+ WIFI_REQUIRED,
+ SYNC_IN_PROGRESS,
+ UNKNOWN_ERROR
+}
+
+/**
+ * Represents the current state of a sync operation.
+ */
+sealed class SyncState {
+ object Idle : SyncState()
+
+ data class Syncing(
+ val currentStep: SyncStep,
+ val progress: Float,
+ val details: String
+ ) : SyncState()
+
+ data class Success(
+ val summary: SyncSummary
+ ) : SyncState()
+
+ data class Error(
+ val error: SyncError,
+ val step: SyncStep,
+ val canRetry: Boolean
+ ) : SyncState()
+}
+
+/**
+ * Steps in the sync process, used for progress tracking.
+ */
+enum class SyncStep {
+ INITIALIZING,
+ SYNCING_FOLDERS,
+ APPLYING_DELETIONS,
+ SYNCING_NOTEBOOKS,
+ DOWNLOADING_NEW,
+ UPLOADING_DELETIONS,
+ FINALIZING
+}
+
+/**
+ * Summary of a completed sync operation.
+ */
+data class SyncSummary(
+ val notebooksSynced: Int,
+ val notebooksDownloaded: Int,
+ val notebooksDeleted: Int,
+ val duration: Long
+)
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncLogger.kt b/app/src/main/java/com/ethran/notable/sync/SyncLogger.kt
new file mode 100644
index 00000000..dae938af
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncLogger.kt
@@ -0,0 +1,81 @@
+package com.ethran.notable.sync
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Logger that maintains recent sync log messages for display in UI.
+ */
+object SyncLogger {
+ private const val MAX_LOGS = 50
+
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs: StateFlow> = _logs.asStateFlow()
+
+ private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
+
+ /**
+ * Add an info log entry.
+ */
+ fun i(tag: String, message: String) {
+ addLog(LogLevel.INFO, tag, message)
+ io.shipbook.shipbooksdk.Log.i(tag, message)
+ }
+
+ /**
+ * Add a warning log entry.
+ */
+ fun w(tag: String, message: String) {
+ addLog(LogLevel.WARNING, tag, message)
+ io.shipbook.shipbooksdk.Log.w(tag, message)
+ }
+
+ /**
+ * Add an error log entry.
+ */
+ fun e(tag: String, message: String) {
+ addLog(LogLevel.ERROR, tag, message)
+ io.shipbook.shipbooksdk.Log.e(tag, message)
+ }
+
+ /**
+ * Clear all log entries.
+ */
+ fun clear() {
+ _logs.value = emptyList()
+ }
+
+ private fun addLog(level: LogLevel, tag: String, message: String) {
+ val entry = LogEntry(
+ timestamp = timeFormat.format(Date()),
+ level = level,
+ tag = tag,
+ message = message
+ )
+
+ val currentLogs = _logs.value.toMutableList()
+ currentLogs.add(entry)
+
+ // Keep only last MAX_LOGS entries
+ if (currentLogs.size > MAX_LOGS) {
+ currentLogs.removeAt(0)
+ }
+
+ _logs.value = currentLogs
+ }
+
+ data class LogEntry(
+ val timestamp: String,
+ val level: LogLevel,
+ val tag: String,
+ val message: String
+ )
+
+ enum class LogLevel {
+ INFO, WARNING, ERROR
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncPaths.kt b/app/src/main/java/com/ethran/notable/sync/SyncPaths.kt
new file mode 100644
index 00000000..9209f3dc
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncPaths.kt
@@ -0,0 +1,37 @@
+package com.ethran.notable.sync
+
+/**
+ * Centralized server path structure for WebDAV sync.
+ * All server paths should be constructed here to prevent spelling mistakes
+ * and make future structural changes easier.
+ */
+object SyncPaths {
+ private const val ROOT = "notable"
+
+ fun rootDir() = "/$ROOT"
+ fun notebooksDir() = "/$ROOT/notebooks"
+ fun tombstonesDir() = "/$ROOT/deletions"
+ fun foldersFile() = "/$ROOT/folders.json"
+
+ fun notebookDir(notebookId: String) = "/$ROOT/notebooks/$notebookId"
+ fun manifestFile(notebookId: String) = "/$ROOT/notebooks/$notebookId/manifest.json"
+ fun pagesDir(notebookId: String) = "/$ROOT/notebooks/$notebookId/pages"
+ fun pageFile(notebookId: String, pageId: String) = "/$ROOT/notebooks/$notebookId/pages/$pageId.json"
+ fun imagesDir(notebookId: String) = "/$ROOT/notebooks/$notebookId/images"
+ fun imageFile(notebookId: String, imageName: String) = "/$ROOT/notebooks/$notebookId/images/$imageName"
+ fun backgroundsDir(notebookId: String) = "/$ROOT/notebooks/$notebookId/backgrounds"
+ fun backgroundFile(notebookId: String, bgName: String) = "/$ROOT/notebooks/$notebookId/backgrounds/$bgName"
+
+ /**
+ * Zero-byte tombstone file for a deleted notebook.
+ * Presence of this file on the server means the notebook was deleted.
+ * This replaces the old deletions.json aggregation file, eliminating the
+ * race condition where two devices could overwrite each other's writes to
+ * that shared file. The server's own lastModified on the tombstone provides
+ * the deletion timestamp needed for conflict resolution.
+ *
+ * TODO: When ETag support is added, tombstones can be deprecated in favour
+ * of detecting deletions via known-ETag + missing remote file (RFC 2518 §9.4).
+ */
+ fun tombstone(notebookId: String) = "/$ROOT/deletions/$notebookId"
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncScheduler.kt b/app/src/main/java/com/ethran/notable/sync/SyncScheduler.kt
new file mode 100644
index 00000000..1e8baf93
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncScheduler.kt
@@ -0,0 +1,63 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+
+/**
+ * Helper to schedule/unschedule background sync with WorkManager.
+ */
+object SyncScheduler {
+
+ // WorkManager enforces a minimum interval of 15 minutes for periodic work.
+ private const val DEFAULT_SYNC_INTERVAL_MINUTES = 15L
+
+ /**
+ * Enable periodic background sync.
+ * @param context Android context
+ * @param intervalMinutes Sync interval in minutes
+ * @param wifiOnly If true, only run on unmetered (WiFi) connections
+ */
+ fun enablePeriodicSync(context: Context, intervalMinutes: Long = DEFAULT_SYNC_INTERVAL_MINUTES, wifiOnly: Boolean = false) {
+ // UNMETERED covers WiFi and ethernet but excludes metered mobile connections.
+ // This matches the intent of the "WiFi only" setting (avoid burning mobile data).
+ val networkType = if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(networkType)
+ .build()
+
+ val syncRequest = PeriodicWorkRequestBuilder(
+ repeatInterval = intervalMinutes,
+ repeatIntervalTimeUnit = TimeUnit.MINUTES
+ )
+ .setConstraints(constraints)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ SyncWorker.WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE, // Update constraints if already scheduled
+ syncRequest
+ )
+ }
+
+ /**
+ * Disable periodic background sync.
+ * @param context Android context
+ */
+ fun disablePeriodicSync(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(SyncWorker.WORK_NAME)
+ }
+
+ /**
+ * Trigger an immediate sync (one-time work).
+ * @param context Android context
+ */
+ fun triggerImmediateSync(context: Context) {
+ // TODO: Implement one-time sync work request
+ // For now, just trigger through SyncEngine directly
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncWorker.kt b/app/src/main/java/com/ethran/notable/sync/SyncWorker.kt
new file mode 100644
index 00000000..e7b945fa
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncWorker.kt
@@ -0,0 +1,103 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.ethran.notable.APP_SETTINGS_KEY
+import com.ethran.notable.data.datastore.AppSettings
+import com.ethran.notable.data.db.KvProxy
+import dagger.hilt.android.EntryPointAccessors
+import io.shipbook.shipbooksdk.Log
+
+/**
+ * Background worker for periodic WebDAV synchronization.
+ * Runs via WorkManager on a periodic schedule (minimum 15 minutes per WorkManager constraints).
+ */
+class SyncWorker(
+ context: Context,
+ params: WorkerParameters
+) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ Log.i(TAG, "SyncWorker started")
+
+ // Check connectivity first
+ val connectivityChecker = ConnectivityChecker(applicationContext)
+ if (!connectivityChecker.isNetworkAvailable()) {
+ Log.i(TAG, "No network available, will retry later")
+ return Result.retry()
+ }
+
+ val entryPoint = EntryPointAccessors.fromApplication(
+ applicationContext, SyncEngine.SyncEngineEntryPoint::class.java
+ )
+ val kvProxy = entryPoint.kvProxy()
+ val settings = kvProxy.get(APP_SETTINGS_KEY, AppSettings.serializer())
+ if (settings?.syncSettings?.wifiOnly == true && !connectivityChecker.isUnmeteredConnected()) {
+ Log.i(TAG, "WiFi-only sync enabled but not on unmetered network, skipping")
+ return Result.success()
+ }
+
+ // Check if we have credentials
+ val credentialManager = CredentialManager(applicationContext)
+ if (!credentialManager.hasCredentials()) {
+ Log.w(TAG, "No credentials stored, skipping sync")
+ return Result.success()
+ }
+
+ // Perform sync
+ return try {
+ val syncEngine = SyncEngine(applicationContext)
+ val result = syncEngine.syncAllNotebooks()
+
+ when (result) {
+ is SyncResult.Success -> {
+ Log.i(TAG, "Sync completed successfully")
+ Result.success()
+ }
+ is SyncResult.Failure -> {
+ when (result.error) {
+ SyncError.SYNC_IN_PROGRESS -> {
+ Log.i(TAG, "Sync already in progress, skipping this run")
+ // Don't retry - another sync is already running
+ Result.success()
+ }
+ SyncError.NETWORK_ERROR -> {
+ Log.e(TAG, "Network error during sync")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure()
+ }
+ }
+ else -> {
+ Log.e(TAG, "Sync failed: ${result.error}")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure()
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error in SyncWorker: ${e.message}")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "SyncWorker"
+ private const val MAX_RETRY_ATTEMPTS = 3
+
+ /**
+ * Unique work name for periodic sync.
+ */
+ const val WORK_NAME = "notable-periodic-sync"
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt b/app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt
new file mode 100644
index 00000000..3ce6ead1
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt
@@ -0,0 +1,645 @@
+package com.ethran.notable.sync
+
+import io.shipbook.shipbooksdk.Log
+import okhttp3.Credentials
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.Closeable
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.StringReader
+import java.net.HttpURLConnection
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
+
+/**
+ * A remote WebDAV collection entry with its name and last-modified timestamp.
+ */
+data class RemoteEntry(val name: String, val lastModified: Date?)
+
+/**
+ * Wrapper for streaming file downloads that properly manages the underlying HTTP response.
+ * This class ensures that both the InputStream and the HTTP Response are properly closed.
+ *
+ * Usage:
+ * ```
+ * webdavClient.getFileStream(path).use { streamResponse ->
+ * streamResponse.inputStream.copyTo(outputStream)
+ * }
+ * ```
+ */
+class StreamResponse(
+ private val response: Response,
+ val inputStream: InputStream
+) : Closeable {
+ override fun close() {
+ try {
+ inputStream.close()
+ } catch (e: Exception) {
+ // Ignore input stream close errors
+ }
+ try {
+ response.close()
+ } catch (e: Exception) {
+ // Ignore response close errors
+ }
+ }
+}
+
+/**
+ * WebDAV client built on OkHttp for Notable sync operations.
+ * Supports basic authentication and common WebDAV methods.
+ */
+class WebDAVClient(
+ private val serverUrl: String,
+ private val username: String,
+ private val password: String
+) {
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+
+ private val credentials = Credentials.basic(username, password)
+
+ /**
+ * Test connection to WebDAV server.
+ * @return true if connection successful, false otherwise
+ */
+ fun testConnection(): Boolean {
+ return try {
+ Log.i(TAG, "Testing connection to: $serverUrl")
+ val request = Request.Builder()
+ .url(serverUrl)
+ .head()
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ Log.i(TAG, "Response code: ${response.code}")
+ response.isSuccessful
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Connection test failed: ${e.message}", e)
+ false
+ }
+ }
+
+ /**
+ * Get the server's current time from the Date response header.
+ * Makes a HEAD request and parses the RFC 1123 Date header.
+ * @return Server time as epoch millis, or null if unavailable/unparseable
+ */
+ fun getServerTime(): Long? {
+ return try {
+ val request = Request.Builder()
+ .url(serverUrl)
+ .head()
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use null
+ val dateHeader = response.header("Date") ?: return@use null
+ parseHttpDate(dateHeader)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to get server time: ${e.message}")
+ null
+ }
+ }
+
+ /**
+ * Check if a resource exists on the server.
+ * @param path Resource path relative to server URL
+ * @return true if resource exists
+ */
+ fun exists(path: String): Boolean {
+ return try {
+ val url = buildUrl(path)
+ val request = Request.Builder()
+ .url(url)
+ .head()
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ response.code == HttpURLConnection.HTTP_OK
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "exists($path) check failed: ${e.message}")
+ false
+ }
+ }
+
+ /**
+ * Create a WebDAV collection (directory).
+ * @param path Collection path relative to server URL
+ * @throws IOException if creation fails
+ */
+ fun createCollection(path: String) {
+ val url = buildUrl(path)
+ val request = Request.Builder()
+ .url(url)
+ .method("MKCOL", null)
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful && response.code != 405) {
+ // 405 Method Not Allowed means collection already exists, which is fine
+ throw IOException("Failed to create collection: ${response.code} ${response.message}")
+ }
+ }
+ }
+
+ /**
+ * Upload a file to the WebDAV server.
+ * @param path Remote path relative to server URL
+ * @param content File content as ByteArray
+ * @param contentType MIME type of the content
+ * @throws IOException if upload fails
+ */
+ fun putFile(path: String, content: ByteArray, contentType: String = "application/octet-stream") {
+ val url = buildUrl(path)
+ val mediaType = contentType.toMediaType()
+ val requestBody = content.toRequestBody(mediaType)
+
+ val request = Request.Builder()
+ .url(url)
+ .put(requestBody)
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IOException("Failed to upload file: ${response.code} ${response.message}")
+ }
+ }
+ }
+
+ /**
+ * Upload a file from local filesystem.
+ * @param path Remote path relative to server URL
+ * @param localFile Local file to upload
+ * @param contentType MIME type of the content
+ * @throws IOException if upload fails
+ */
+ fun putFile(path: String, localFile: File, contentType: String = "application/octet-stream") {
+ if (!localFile.exists()) {
+ throw IOException("Local file does not exist: ${localFile.absolutePath}")
+ }
+ putFile(path, localFile.readBytes(), contentType)
+ }
+
+ /**
+ * Download a file from the WebDAV server.
+ * @param path Remote path relative to server URL
+ * @return File content as ByteArray
+ * @throws IOException if download fails
+ */
+ fun getFile(path: String): ByteArray {
+ val url = buildUrl(path)
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IOException("Failed to download file: ${response.code} ${response.message}")
+ }
+ return response.body?.bytes() ?: throw IOException("Empty response body")
+ }
+ }
+
+ /**
+ * Download a file and save it to local filesystem.
+ * @param path Remote path relative to server URL
+ * @param localFile Local file to save to
+ * @throws IOException if download or save fails
+ */
+ fun getFile(path: String, localFile: File) {
+ val content = getFile(path)
+ localFile.parentFile?.mkdirs()
+ localFile.writeBytes(content)
+ }
+
+ /**
+ * Get file as InputStream for streaming large files.
+ * Returns a StreamResponse that wraps both the InputStream and underlying HTTP Response.
+ * IMPORTANT: Caller MUST close the StreamResponse (use .use {} block) to prevent resource leaks.
+ *
+ * Example usage:
+ * ```
+ * webdavClient.getFileStream(path).use { streamResponse ->
+ * streamResponse.inputStream.copyTo(outputStream)
+ * }
+ * ```
+ *
+ * @param path Remote path relative to server URL
+ * @return StreamResponse containing InputStream and managing underlying HTTP connection
+ * @throws IOException if download fails
+ */
+ fun getFileStream(path: String): StreamResponse {
+ val url = buildUrl(path)
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header("Authorization", credentials)
+ .build()
+
+ val response = client.newCall(request).execute()
+ if (!response.isSuccessful) {
+ response.close()
+ throw IOException("Failed to download file: ${response.code} ${response.message}")
+ }
+
+ val inputStream = response.body?.byteStream()
+ ?: run {
+ response.close()
+ throw IOException("Empty response body")
+ }
+
+ return StreamResponse(response, inputStream)
+ }
+
+ /**
+ * Delete a resource from the WebDAV server.
+ * @param path Resource path relative to server URL
+ * @throws IOException if deletion fails
+ */
+ fun delete(path: String) {
+ val url = buildUrl(path)
+ val request = Request.Builder()
+ .url(url)
+ .delete()
+ .header("Authorization", credentials)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_FOUND) {
+ // 404 means already deleted, which is fine
+ throw IOException("Failed to delete resource: ${response.code} ${response.message}")
+ }
+ }
+ }
+
+ /**
+ * Get last modified timestamp of a resource using PROPFIND.
+ * @param path Resource path relative to server URL
+ * @return Last modified timestamp in ISO 8601 format, or null if not available
+ * @throws IOException if PROPFIND fails
+ */
+ fun getLastModified(path: String): String? {
+ val url = buildUrl(path)
+
+ // WebDAV PROPFIND request body for last-modified
+ val propfindXml = """
+
+
+
+
+
+
+ """.trimIndent()
+
+ val requestBody = propfindXml.toRequestBody("application/xml".toMediaType())
+
+ val request = Request.Builder()
+ .url(url)
+ .method("PROPFIND", requestBody)
+ .header("Authorization", credentials)
+ .header("Depth", "0")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ return null
+ }
+
+ val responseBody = response.body?.string() ?: return null
+
+ // Parse XML response using XmlPullParser to properly handle namespaces and CDATA
+ return parseLastModifiedFromXml(responseBody)
+ }
+ }
+
+ /**
+ * List resources in a collection using PROPFIND.
+ * @param path Collection path relative to server URL
+ * @return List of resource names in the collection
+ * @throws IOException if PROPFIND fails
+ */
+ fun listCollection(path: String): List {
+ val url = buildUrl(path)
+
+ // WebDAV PROPFIND request body for directory listing
+ val propfindXml = """
+
+
+
+
+ """.trimIndent()
+
+ val requestBody = propfindXml.toRequestBody("application/xml".toMediaType())
+
+ val request = Request.Builder()
+ .url(url)
+ .method("PROPFIND", requestBody)
+ .header("Authorization", credentials)
+ .header("Depth", "1")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IOException("Failed to list collection: ${response.code} ${response.message}")
+ }
+
+ val responseBody = response.body?.string() ?: return emptyList()
+ val allHrefs = parseHrefsFromXml(responseBody)
+
+ return allHrefs
+ .filter { it != path && !it.endsWith("/$path") }
+ .map { href -> href.trimEnd('/').substringAfterLast('/') }
+ .filter { isValidUuid(it) }
+ .toList()
+ }
+ }
+
+ /**
+ * List resources in a collection with their last-modified timestamps.
+ * Used for tombstone-based deletion tracking where we need the server's
+ * own timestamp for conflict resolution.
+ * @param path Collection path relative to server URL
+ * @return List of RemoteEntry objects; empty if collection doesn't exist
+ * @throws IOException if PROPFIND fails for a reason other than 404
+ */
+ fun listCollectionWithMetadata(path: String): List {
+ val url = buildUrl(path)
+
+ val propfindXml = """
+
+
+
+
+
+
+ """.trimIndent()
+
+ val requestBody = propfindXml.toRequestBody("application/xml".toMediaType())
+
+ val request = Request.Builder()
+ .url(url)
+ .method("PROPFIND", requestBody)
+ .header("Authorization", credentials)
+ .header("Depth", "1")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (response.code == HttpURLConnection.HTTP_NOT_FOUND) return emptyList()
+ if (!response.isSuccessful) {
+ throw IOException("Failed to list collection: ${response.code} ${response.message}")
+ }
+
+ val responseBody = response.body?.string() ?: return emptyList()
+ return parseEntriesFromXml(responseBody)
+ .filter { (href, _) -> href != path && !href.endsWith("/$path") }
+ .mapNotNull { (href, lastModified) ->
+ val name = href.trimEnd('/').substringAfterLast('/')
+ if (isValidUuid(name)) RemoteEntry(name, lastModified) else null
+ }
+ }
+ }
+
+ /**
+ * Ensure parent directories exist, creating them if necessary.
+ * @param path File path (will create parent directories)
+ * @throws IOException if directory creation fails
+ */
+ fun ensureParentDirectories(path: String) {
+ val segments = path.trimStart('/').split('/')
+ if (segments.size <= 1) return // No parent directories
+
+ var currentPath = ""
+ for (i in 0 until segments.size - 1) {
+ currentPath += "/" + segments[i]
+ if (!exists(currentPath)) {
+ createCollection(currentPath)
+ }
+ }
+ }
+
+ /**
+ * Build full URL from server URL and path.
+ * @param path Relative path
+ * @return Full URL
+ */
+ private fun buildUrl(path: String): String {
+ val normalizedServer = serverUrl.trimEnd('/')
+ val normalizedPath = if (path.startsWith('/')) path else "/$path"
+ return normalizedServer + normalizedPath
+ }
+
+ /**
+ * Parse last modified timestamp from WebDAV XML response.
+ * Properly handles namespaces, CDATA, and whitespace.
+ * @param xml XML response from PROPFIND
+ * @return Last modified timestamp, or null if not found
+ */
+ private fun parseLastModifiedFromXml(xml: String): String? {
+ return try {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG) {
+ // Check for getlastmodified tag (case-insensitive, namespace-aware)
+ val localName = parser.name.lowercase()
+ if (localName == "getlastmodified") {
+ // Get text content, handling CDATA properly
+ if (parser.next() == XmlPullParser.TEXT) {
+ return parser.text.trim()
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+ null
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse XML for last modified: ${e.message}")
+ null
+ }
+ }
+
+ /**
+ * Parse href values from WebDAV XML response.
+ * Properly handles namespaces, CDATA, and whitespace.
+ * @param xml XML response from PROPFIND
+ * @return List of href values
+ */
+ private fun parseHrefsFromXml(xml: String): List {
+ return try {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ val hrefs = mutableListOf()
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG) {
+ // Check for href tag (case-insensitive, namespace-aware)
+ val localName = parser.name.lowercase()
+ if (localName == "href") {
+ // Get text content, handling CDATA properly
+ if (parser.next() == XmlPullParser.TEXT) {
+ hrefs.add(parser.text.trim())
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+ hrefs
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse XML for hrefs: ${e.message}")
+ emptyList()
+ }
+ }
+
+ /**
+ * Parse blocks from a PROPFIND XML response, returning each
+ * resource's href paired with its last-modified date (null if absent).
+ */
+ private fun parseEntriesFromXml(xml: String): List> {
+ return try {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ val entries = mutableListOf>()
+ var currentHref: String? = null
+ var currentLastModified: Date? = null
+ var inResponse = false
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> when (parser.name.lowercase()) {
+ "response" -> {
+ inResponse = true
+ currentHref = null
+ currentLastModified = null
+ }
+ "href" -> if (inResponse && parser.next() == XmlPullParser.TEXT) {
+ currentHref = parser.text.trim()
+ }
+ "getlastmodified" -> if (inResponse && parser.next() == XmlPullParser.TEXT) {
+ currentLastModified = parseHttpDate(parser.text.trim())?.let { Date(it) }
+ }
+ }
+ XmlPullParser.END_TAG -> if (parser.name.lowercase() == "response" && inResponse) {
+ currentHref?.let { entries.add(it to currentLastModified) }
+ inResponse = false
+ }
+ }
+ eventType = parser.next()
+ }
+ entries
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse XML entries: ${e.message}")
+ emptyList()
+ }
+ }
+
+ private fun isValidUuid(name: String): Boolean =
+ name.length == UUID_LENGTH &&
+ name[UUID_DASH_POS_1] == '-' &&
+ name[UUID_DASH_POS_2] == '-' &&
+ name[UUID_DASH_POS_3] == '-' &&
+ name[UUID_DASH_POS_4] == '-'
+
+ companion object {
+ private const val TAG = "WebDAVClient"
+
+ // Timeout constants
+ private const val CONNECT_TIMEOUT_SECONDS = 30L
+ private const val READ_TIMEOUT_SECONDS = 60L
+ private const val WRITE_TIMEOUT_SECONDS = 60L
+
+ // UUID validation constants
+ private const val UUID_LENGTH = 36
+ private const val UUID_DASH_POS_1 = 8
+ private const val UUID_DASH_POS_2 = 13
+ private const val UUID_DASH_POS_3 = 18
+ private const val UUID_DASH_POS_4 = 23
+
+ // RFC 1123 date format used in HTTP Date headers
+ private const val HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
+
+ /**
+ * Parse an HTTP Date header (RFC 1123 format) to epoch millis.
+ * @return Epoch millis or null if unparseable
+ */
+ fun parseHttpDate(dateHeader: String): Long? {
+ return try {
+ val sdf = SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("GMT")
+ sdf.parse(dateHeader)?.time
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Factory method to test connection and detect clock skew.
+ * @return Pair of (connectionSuccessful, clockSkewMs) where clockSkewMs is
+ * the difference (deviceTime - serverTime) in milliseconds, or null
+ * if the server did not return a Date header.
+ */
+ fun testConnection(serverUrl: String, username: String, password: String): Pair {
+ return try {
+ val client = WebDAVClient(serverUrl, username, password)
+ val connected = client.testConnection()
+ val clockSkewMs = if (connected) {
+ client.getServerTime()?.let { serverTime ->
+ System.currentTimeMillis() - serverTime
+ }
+ } else {
+ null
+ }
+ Pair(connected, clockSkewMs)
+ } catch (e: Exception) {
+ Log.e(TAG, "Connection test failed: ${e.message}", e)
+ Pair(false, null)
+ }
+ }
+
+ /**
+ * Factory method to get server time without full initialization.
+ * @return Server time as epoch millis, or null if unavailable
+ */
+ fun getServerTime(serverUrl: String, username: String, password: String): Long? {
+ return try {
+ WebDAVClient(serverUrl, username, password).getServerTime()
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt
index 3c0f6714..3e69645d 100644
--- a/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt
+++ b/app/src/main/java/com/ethran/notable/ui/components/GeneralSettings.kt
@@ -72,6 +72,13 @@ fun GeneralSettings(
onSettingsChange(settings.copy(monochromeMode = isChecked))
})
+ SettingToggleRow(
+ label = stringResource(R.string.rename_on_create),
+ value = settings.renameOnCreate,
+ onToggle = { isChecked ->
+ onSettingsChange(settings.copy(renameOnCreate = isChecked))
+ })
+
SettingToggleRow(
label = stringResource(R.string.paginate_pdf),
value = settings.paginatePdf,
diff --git a/app/src/main/java/com/ethran/notable/ui/dialogs/NotebookConfig.kt b/app/src/main/java/com/ethran/notable/ui/dialogs/NotebookConfig.kt
index b6ef75e2..34a7f363 100644
--- a/app/src/main/java/com/ethran/notable/ui/dialogs/NotebookConfig.kt
+++ b/app/src/main/java/com/ethran/notable/ui/dialogs/NotebookConfig.kt
@@ -37,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
@@ -63,6 +64,7 @@ import com.ethran.notable.ui.LocalSnackContext
import com.ethran.notable.ui.SnackConf
import com.ethran.notable.ui.components.BreadCrumb
import com.ethran.notable.ui.components.PagePreview
+import com.ethran.notable.sync.SyncEngine
import com.ethran.notable.ui.components.getFolderList
import io.shipbook.shipbooksdk.ShipBook
import kotlinx.coroutines.launch
@@ -80,6 +82,7 @@ fun NotebookConfigDialog(
val book by bookRepository.getByIdLive(bookId).observeAsState()
val scope = rememberCoroutineScope()
val snackManager = LocalSnackContext.current
+ val context = LocalContext.current
if (book == null) return
@@ -141,6 +144,16 @@ fun NotebookConfigDialog(
}
showDeleteDialog = false
onClose()
+
+ // Auto-upload deletion to server (use standalone scope since composition is closing)
+ kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
+ try {
+ log.i("Uploading deletion for notebook: $bookId")
+ SyncEngine(context).uploadDeletion(bookId)
+ } catch (e: Exception) {
+ log.e("Upload deletion failed: ${e.message}")
+ }
+ }
},
onCancel = {
showDeleteDialog = false
diff --git a/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt b/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt
index 1a2afcea..9a5d0c62 100644
--- a/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt
+++ b/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt
@@ -63,6 +63,8 @@ class LibraryViewModel @Inject constructor(
private val _folderId = MutableStateFlow(null)
private val _isImporting = MutableStateFlow(false)
+ private val _newlyCreatedBookId = MutableStateFlow(null)
+ val newlyCreatedBookId: StateFlow = _newlyCreatedBookId
private val _isLatestVersion = MutableStateFlow(true)
private val _breadcrumbFolders = MutableStateFlow>(emptyList())
@@ -152,17 +154,20 @@ class LibraryViewModel @Inject constructor(
fun onCreateNewNotebook() {
viewModelScope.launch(Dispatchers.IO) {
val settings = GlobalAppSettings.current
-
- bookRepository.create(
- Notebook(
- parentFolderId = _folderId.value,
- defaultBackground = settings.defaultNativeTemplate,
- defaultBackgroundType = BackgroundType.Native.key
- )
+ val notebook = Notebook(
+ parentFolderId = _folderId.value,
+ defaultBackground = settings.defaultNativeTemplate,
+ defaultBackgroundType = BackgroundType.Native.key
)
+ bookRepository.create(notebook)
+ _newlyCreatedBookId.value = notebook.id
}
}
+ fun clearNewlyCreatedBookId() {
+ _newlyCreatedBookId.value = null
+ }
+
fun onPdfFile(uri: Uri, copy: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val snackText =
diff --git a/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt b/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt
index 9f63886c..08a67be1 100644
--- a/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt
+++ b/app/src/main/java/com/ethran/notable/ui/views/HomeView.kt
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.ethran.notable.R
import com.ethran.notable.data.AppRepository
+import com.ethran.notable.data.datastore.GlobalAppSettings
import com.ethran.notable.data.db.Folder
import com.ethran.notable.data.db.Notebook
import com.ethran.notable.editor.EditorDestination
@@ -97,11 +98,26 @@ fun Library(
viewModel: LibraryViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val newlyCreatedBookId by viewModel.newlyCreatedBookId.collectAsStateWithLifecycle()
LaunchedEffect(folderId) {
viewModel.loadFolder(folderId)
}
+ // Show config dialog for newly created notebooks so user can rename immediately
+ if (newlyCreatedBookId != null) {
+ if (GlobalAppSettings.current.renameOnCreate && uiState.books.any { it.id == newlyCreatedBookId }) {
+ NotebookConfigDialog(
+ appRepository = viewModel.appRepository,
+ exportEngine = viewModel.exportEngine,
+ bookId = newlyCreatedBookId!!,
+ onClose = { viewModel.clearNewlyCreatedBookId() }
+ )
+ } else {
+ viewModel.clearNewlyCreatedBookId()
+ }
+ }
+
LibraryContent(
appRepository = viewModel.appRepository,
exportEngine = viewModel.exportEngine,
diff --git a/app/src/main/java/com/ethran/notable/ui/views/Settings.kt b/app/src/main/java/com/ethran/notable/ui/views/Settings.kt
index 2c9a99ee..0434ca48 100644
--- a/app/src/main/java/com/ethran/notable/ui/views/Settings.kt
+++ b/app/src/main/java/com/ethran/notable/ui/views/Settings.kt
@@ -120,10 +120,12 @@ fun SettingsContent(
listOfGestures: List = emptyList(),
availableGestures: List> = emptyList()
) {
+ val context = LocalContext.current
var selectedTab by remember { mutableIntStateOf(selectedTabInitial) }
val tabs = listOf(
stringResource(R.string.settings_tab_general_name),
stringResource(R.string.settings_tab_gestures_name),
+ stringResource(R.string.settings_tab_sync_name),
stringResource(R.string.settings_tab_debug_name)
)
@@ -153,7 +155,8 @@ fun SettingsContent(
settings, onUpdateSettings, listOfGestures, availableGestures
)
- 2 -> DebugSettings(settings, onUpdateSettings, goToWelcome, goToSystemInfo)
+ 2 -> SyncSettings(settings, onUpdateSettings, context)
+ 3 -> DebugSettings(settings, onUpdateSettings, goToWelcome, goToSystemInfo)
}
}
diff --git a/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt
new file mode 100644
index 00000000..4f6f92b2
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt
@@ -0,0 +1,820 @@
+package com.ethran.notable.ui.views
+
+import android.content.Context
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.ethran.notable.R
+import com.ethran.notable.data.datastore.AppSettings
+import com.ethran.notable.data.datastore.GlobalAppSettings
+import com.ethran.notable.data.datastore.SyncSettings
+import com.ethran.notable.ui.components.SettingToggleRow
+import com.ethran.notable.ui.components.SettingsDivider
+import com.ethran.notable.sync.CredentialManager
+import com.ethran.notable.sync.SyncEngine
+import com.ethran.notable.sync.SyncLogger
+import com.ethran.notable.sync.SyncResult
+import com.ethran.notable.sync.SyncScheduler
+import com.ethran.notable.sync.SyncState
+import com.ethran.notable.sync.WebDAVClient
+import com.ethran.notable.ui.showHint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun SyncSettings(settings: AppSettings, onUpdateSettings: (AppSettings) -> Unit, context: Context) {
+ val syncSettings = settings.syncSettings
+ val credentialManager = remember { CredentialManager(context) }
+ val scope = rememberCoroutineScope()
+
+ var serverUrl by remember { mutableStateOf(syncSettings.serverUrl) }
+ var username by remember { mutableStateOf(syncSettings.username) }
+ var password by remember { mutableStateOf("") }
+ var savedUsername by remember { mutableStateOf(syncSettings.username) }
+ var savedPassword by remember { mutableStateOf("") }
+ var testingConnection by remember { mutableStateOf(false) }
+ var syncInProgress by remember { mutableStateOf(false) }
+ var connectionStatus by remember { mutableStateOf(null) }
+
+ // Observe sync logs
+ val syncLogs by SyncLogger.logs.collectAsState()
+
+ // Load password from CredentialManager on first composition
+ LaunchedEffect(Unit) {
+ credentialManager.getCredentials()?.let { (user, pass) ->
+ username = user
+ password = pass
+ savedUsername = user
+ savedPassword = pass
+ SyncLogger.i("Settings", "Loaded credentials for user: $user")
+ } ?: SyncLogger.w("Settings", "No credentials found in storage")
+ }
+
+ // Check if credentials have changed
+ val credentialsChanged = username != savedUsername || password != savedPassword
+
+ Column(modifier = Modifier.padding(vertical = 8.dp)) {
+ Text(
+ text = stringResource(R.string.sync_title),
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ // Enable/Disable Sync Toggle
+ SyncEnableToggle(
+ syncSettings = syncSettings,
+ settings = settings,
+ onUpdateSettings = onUpdateSettings,
+ context = context
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Credential Fields
+ SyncCredentialFields(
+ serverUrl = serverUrl,
+ username = username,
+ password = password,
+ onServerUrlChange = {
+ serverUrl = it
+ onUpdateSettings(settings.copy(syncSettings = syncSettings.copy(serverUrl = it)))
+ },
+ onUsernameChange = { username = it },
+ onPasswordChange = { password = it }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Save Credentials Button
+ Button(
+ onClick = {
+ credentialManager.saveCredentials(username, password)
+ savedUsername = username
+ savedPassword = password
+ onUpdateSettings(settings.copy(syncSettings = syncSettings.copy(username = username)))
+ SyncLogger.i("Settings", "Credentials saved for user: $username")
+ showHint(context.getString(R.string.sync_credentials_saved), scope)
+ },
+ enabled = credentialsChanged && username.isNotEmpty() && password.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color(0, 120, 200),
+ contentColor = Color.White,
+ disabledBackgroundColor = Color(200, 200, 200),
+ disabledContentColor = Color.Gray
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .height(48.dp)
+ ) {
+ Text(stringResource(R.string.sync_save_credentials), fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Test Connection Button and Status
+ SyncConnectionTest(
+ serverUrl = serverUrl,
+ username = username,
+ password = password,
+ testingConnection = testingConnection,
+ connectionStatus = connectionStatus,
+ onTestConnection = {
+ testingConnection = true
+ connectionStatus = null
+ scope.launch(Dispatchers.IO) {
+ val (connected, clockSkewMs) = WebDAVClient.testConnection(serverUrl, username, password)
+ withContext(Dispatchers.Main) {
+ testingConnection = false
+ connectionStatus = when {
+ !connected -> context.getString(R.string.sync_connection_failed)
+ clockSkewMs != null && kotlin.math.abs(clockSkewMs) > 30_000L ->
+ context.getString(R.string.sync_clock_skew_warning, clockSkewMs / 1000)
+ else -> context.getString(R.string.sync_connected_successfully)
+ }
+ }
+ }
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ SettingsDivider()
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Sync Controls (auto-sync and sync on close)
+ SyncControlToggles(
+ syncSettings = syncSettings,
+ settings = settings,
+ onUpdateSettings = onUpdateSettings,
+ context = context
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ SettingsDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Manual Sync Button
+ ManualSyncButton(
+ syncInProgress = syncInProgress,
+ syncSettings = syncSettings,
+ serverUrl = serverUrl,
+ context = context,
+ onUpdateSettings = onUpdateSettings,
+ scope = scope,
+ onSyncStateChange = { syncInProgress = it }
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ SettingsDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Danger Zone: Force Operations
+ ForceOperationsSection(
+ syncSettings = syncSettings,
+ serverUrl = serverUrl,
+ context = context,
+ scope = scope,
+ onSyncStateChange = { syncInProgress = it }
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ SettingsDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Sync Log Viewer
+ SyncLogViewer(syncLogs = syncLogs)
+ }
+}
+
+@Composable
+fun SyncEnableToggle(
+ syncSettings: SyncSettings,
+ settings: AppSettings,
+ onUpdateSettings: (AppSettings) -> Unit,
+ context: Context
+) {
+ SettingToggleRow(
+ label = stringResource(R.string.sync_enable_label),
+ value = syncSettings.syncEnabled,
+ onToggle = { isChecked ->
+ onUpdateSettings(
+ settings.copy(syncSettings = syncSettings.copy(syncEnabled = isChecked))
+ )
+ // Enable/disable WorkManager sync
+ if (isChecked && syncSettings.autoSync) {
+ SyncScheduler.enablePeriodicSync(context, syncSettings.syncInterval.toLong(), syncSettings.wifiOnly)
+ } else {
+ SyncScheduler.disablePeriodicSync(context)
+ }
+ }
+ )
+}
+
+@Composable
+fun SyncCredentialFields(
+ serverUrl: String,
+ username: String,
+ password: String,
+ onServerUrlChange: (String) -> Unit,
+ onUsernameChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit
+) {
+ // Server URL Field
+ Column(modifier = Modifier.padding(horizontal = 4.dp)) {
+ Text(
+ text = stringResource(R.string.sync_server_url_note),
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ Text(
+ text = stringResource(R.string.sync_server_url_label),
+ style = MaterialTheme.typography.body2,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ BasicTextField(
+ value = serverUrl,
+ onValueChange = onServerUrlChange,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color(230, 230, 230, 255))
+ .padding(12.dp),
+ decorationBox = { innerTextField ->
+ Box {
+ if (serverUrl.isEmpty()) {
+ Text(
+ stringResource(R.string.sync_server_url_placeholder),
+ style = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = Color.Gray
+ )
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Username Field
+ Column(modifier = Modifier.padding(horizontal = 4.dp)) {
+ Text(
+ text = stringResource(R.string.sync_username_label),
+ style = MaterialTheme.typography.body2,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ BasicTextField(
+ value = username,
+ onValueChange = onUsernameChange,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color(230, 230, 230, 255))
+ .padding(12.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Password Field
+ Column(modifier = Modifier.padding(horizontal = 4.dp)) {
+ Text(
+ text = stringResource(R.string.sync_password_label),
+ style = MaterialTheme.typography.body2,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ BasicTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color(230, 230, 230, 255))
+ .padding(12.dp)
+ )
+ }
+}
+
+@Composable
+fun SyncConnectionTest(
+ serverUrl: String,
+ username: String,
+ password: String,
+ testingConnection: Boolean,
+ connectionStatus: String?,
+ onTestConnection: () -> Unit
+) {
+ Button(
+ onClick = onTestConnection,
+ enabled = !testingConnection && serverUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color(80, 80, 80),
+ contentColor = Color.White,
+ disabledBackgroundColor = Color(200, 200, 200),
+ disabledContentColor = Color.Gray
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .height(48.dp)
+ ) {
+ if (testingConnection) {
+ Text(stringResource(R.string.sync_testing_connection))
+ } else {
+ Text(stringResource(R.string.sync_test_connection), fontWeight = FontWeight.Bold)
+ }
+ }
+
+ connectionStatus?.let { status ->
+ val statusColor = when {
+ status.startsWith("✓") -> Color(0, 150, 0)
+ status.startsWith("✗") -> Color(200, 0, 0)
+ else -> Color(200, 100, 0) // Warning (e.g. clock skew)
+ }
+ Text(
+ text = status,
+ style = MaterialTheme.typography.body2,
+ color = statusColor,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+}
+
+@Composable
+fun SyncControlToggles(
+ syncSettings: SyncSettings,
+ settings: AppSettings,
+ onUpdateSettings: (AppSettings) -> Unit,
+ context: Context
+) {
+ SettingToggleRow(
+ label = stringResource(R.string.sync_auto_sync_label, syncSettings.syncInterval),
+ value = syncSettings.autoSync,
+ onToggle = { isChecked ->
+ onUpdateSettings(
+ settings.copy(syncSettings = syncSettings.copy(autoSync = isChecked))
+ )
+ if (isChecked && syncSettings.syncEnabled) {
+ SyncScheduler.enablePeriodicSync(context, syncSettings.syncInterval.toLong(), syncSettings.wifiOnly)
+ } else {
+ SyncScheduler.disablePeriodicSync(context)
+ }
+ }
+ )
+
+ SettingToggleRow(
+ label = stringResource(R.string.sync_on_note_close_label),
+ value = syncSettings.syncOnNoteClose,
+ onToggle = { isChecked ->
+ onUpdateSettings(
+ settings.copy(syncSettings = syncSettings.copy(syncOnNoteClose = isChecked))
+ )
+ }
+ )
+
+ SettingToggleRow(
+ label = stringResource(R.string.sync_wifi_only_label),
+ value = syncSettings.wifiOnly,
+ onToggle = { isChecked ->
+ onUpdateSettings(
+ settings.copy(syncSettings = syncSettings.copy(wifiOnly = isChecked))
+ )
+ // Re-schedule background sync with updated network constraint
+ if (syncSettings.autoSync && syncSettings.syncEnabled) {
+ SyncScheduler.enablePeriodicSync(context, syncSettings.syncInterval.toLong(), isChecked)
+ }
+ }
+ )
+}
+
+@Composable
+fun ManualSyncButton(
+ syncInProgress: Boolean,
+ syncSettings: SyncSettings,
+ serverUrl: String,
+ context: Context,
+ onUpdateSettings: (AppSettings) -> Unit,
+ scope: kotlinx.coroutines.CoroutineScope,
+ onSyncStateChange: (Boolean) -> Unit
+) {
+ // Observe sync state from SyncEngine
+ val syncState by SyncEngine.syncState.collectAsState()
+
+ Column {
+ Button(
+ onClick = {
+ onSyncStateChange(true)
+ scope.launch(Dispatchers.IO) {
+ val result = SyncEngine(context).syncAllNotebooks()
+ withContext(Dispatchers.Main) {
+ onSyncStateChange(false)
+ if (result is SyncResult.Success) {
+ val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
+ val latestSettings = GlobalAppSettings.current
+ onUpdateSettings(
+ latestSettings.copy(
+ syncSettings = latestSettings.syncSettings.copy(lastSyncTime = timestamp)
+ )
+ )
+ showHint(context.getString(R.string.sync_completed_successfully), scope)
+ } else {
+ showHint(context.getString(R.string.sync_failed_message, (result as? SyncResult.Failure)?.error.toString()), scope)
+ }
+ }
+ }
+ },
+ enabled = syncState is SyncState.Idle && syncSettings.syncEnabled && serverUrl.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = when (syncState) {
+ is SyncState.Success -> Color(0, 150, 0)
+ is SyncState.Error -> Color(200, 0, 0)
+ else -> Color(0, 120, 200)
+ },
+ contentColor = Color.White,
+ disabledBackgroundColor = Color(200, 200, 200),
+ disabledContentColor = Color.Gray
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .height(56.dp)
+ ) {
+ when (val state = syncState) {
+ is SyncState.Idle -> Text(stringResource(R.string.sync_now), fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ is SyncState.Syncing -> Text(
+ stringResource(R.string.sync_progress_details, state.details, (state.progress * 100).toInt()),
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp
+ )
+ is SyncState.Success -> Text(stringResource(R.string.sync_synced), fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ is SyncState.Error -> Text(stringResource(R.string.sync_failed), fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ }
+ }
+
+ // Progress indicator
+ if (syncState is SyncState.Syncing) {
+ androidx.compose.material.LinearProgressIndicator(
+ progress = (syncState as SyncState.Syncing).progress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 4.dp, end = 4.dp, top = 4.dp),
+ color = Color(0, 120, 200)
+ )
+ }
+
+ // Success summary
+ if (syncState is SyncState.Success) {
+ val summary = (syncState as SyncState.Success).summary
+ Text(
+ text = stringResource(
+ R.string.sync_summary,
+ summary.notebooksSynced,
+ summary.notebooksDownloaded,
+ summary.notebooksDeleted,
+ summary.duration
+ ),
+ style = MaterialTheme.typography.caption,
+ color = Color(0, 150, 0),
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+
+ // Error details
+ if (syncState is SyncState.Error) {
+ val error = syncState as SyncState.Error
+ val errorText = if (error.error == com.ethran.notable.sync.SyncError.WIFI_REQUIRED) {
+ stringResource(R.string.sync_wifi_required_message)
+ } else {
+ stringResource(
+ R.string.sync_error_at_step,
+ error.step.toString(),
+ error.error.toString(),
+ if (error.canRetry) stringResource(R.string.sync_can_retry) else ""
+ )
+ }
+ Text(
+ text = errorText,
+ style = MaterialTheme.typography.caption,
+ color = Color(200, 0, 0),
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+
+ // Last sync time
+ syncSettings.lastSyncTime?.let { timestamp ->
+ Text(
+ text = stringResource(R.string.sync_last_synced, timestamp),
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun ForceOperationsSection(
+ syncSettings: SyncSettings,
+ serverUrl: String,
+ context: Context,
+ scope: kotlinx.coroutines.CoroutineScope,
+ onSyncStateChange: (Boolean) -> Unit
+) {
+ Text(
+ text = stringResource(R.string.sync_force_operations_title),
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ color = Color(200, 0, 0),
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.sync_force_operations_warning),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 16.dp, start = 4.dp, end = 4.dp)
+ )
+
+ var showForceUploadConfirm by remember { mutableStateOf(false) }
+ Button(
+ onClick = { showForceUploadConfirm = true },
+ enabled = syncSettings.syncEnabled && serverUrl.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color(200, 100, 0),
+ contentColor = Color.White,
+ disabledBackgroundColor = Color(200, 200, 200),
+ disabledContentColor = Color.Gray
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .height(48.dp)
+ ) {
+ Text(stringResource(R.string.sync_force_upload_button), fontWeight = FontWeight.Bold)
+ }
+
+ if (showForceUploadConfirm) {
+ ConfirmationDialog(
+ title = stringResource(R.string.sync_confirm_force_upload_title),
+ message = stringResource(R.string.sync_confirm_force_upload_message),
+ onConfirm = {
+ showForceUploadConfirm = false
+ onSyncStateChange(true)
+ scope.launch(Dispatchers.IO) {
+ val result = SyncEngine(context).forceUploadAll()
+ withContext(Dispatchers.Main) {
+ onSyncStateChange(false)
+ showHint(
+ if (result is SyncResult.Success)
+ context.getString(R.string.sync_force_upload_success)
+ else
+ context.getString(R.string.sync_force_upload_failed),
+ scope
+ )
+ }
+ }
+ },
+ onDismiss = { showForceUploadConfirm = false }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ var showForceDownloadConfirm by remember { mutableStateOf(false) }
+ Button(
+ onClick = { showForceDownloadConfirm = true },
+ enabled = syncSettings.syncEnabled && serverUrl.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color(200, 0, 0),
+ contentColor = Color.White,
+ disabledBackgroundColor = Color(200, 200, 200),
+ disabledContentColor = Color.Gray
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .height(48.dp)
+ ) {
+ Text(stringResource(R.string.sync_force_download_button), fontWeight = FontWeight.Bold)
+ }
+
+ if (showForceDownloadConfirm) {
+ ConfirmationDialog(
+ title = stringResource(R.string.sync_confirm_force_download_title),
+ message = stringResource(R.string.sync_confirm_force_download_message),
+ onConfirm = {
+ showForceDownloadConfirm = false
+ onSyncStateChange(true)
+ scope.launch(Dispatchers.IO) {
+ val result = SyncEngine(context).forceDownloadAll()
+ withContext(Dispatchers.Main) {
+ onSyncStateChange(false)
+ showHint(
+ if (result is SyncResult.Success)
+ context.getString(R.string.sync_force_download_success)
+ else
+ context.getString(R.string.sync_force_download_failed),
+ scope
+ )
+ }
+ }
+ },
+ onDismiss = { showForceDownloadConfirm = false }
+ )
+ }
+}
+
+@Composable
+fun SyncLogViewer(syncLogs: List) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.sync_log_title),
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold
+ )
+ Button(
+ onClick = { SyncLogger.clear() },
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ modifier = Modifier.height(32.dp)
+ ) {
+ Text(stringResource(R.string.sync_clear_log), fontSize = 12.sp)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(300.dp)
+ .background(Color(250, 250, 250))
+ .border(1.dp, Color.Gray)
+ ) {
+ val scrollState = rememberScrollState()
+
+ LaunchedEffect(syncLogs.size) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+
+ if (syncLogs.isEmpty()) {
+ Text(
+ text = stringResource(R.string.sync_log_empty),
+ style = MaterialTheme.typography.body2,
+ color = Color.Gray,
+ modifier = Modifier.padding(12.dp)
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState)
+ .padding(8.dp)
+ ) {
+ syncLogs.takeLast(20).forEach { log ->
+ val logColor = when (log.level) {
+ SyncLogger.LogLevel.INFO -> Color(0, 100, 0)
+ SyncLogger.LogLevel.WARNING -> Color(200, 100, 0)
+ SyncLogger.LogLevel.ERROR -> Color(200, 0, 0)
+ }
+ Text(
+ text = "[${log.timestamp}] ${log.message}",
+ style = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ color = logColor
+ ),
+ modifier = Modifier.padding(vertical = 1.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ConfirmationDialog(
+ title: String,
+ message: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier
+ .background(Color.White)
+ .border(2.dp, Color.Black, RectangleShape)
+ .padding(24.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.body1,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color.Gray,
+ contentColor = Color.White
+ )
+ ) {
+ Text(stringResource(R.string.sync_dialog_cancel))
+ }
+
+ Button(
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color(200, 0, 0),
+ contentColor = Color.White
+ )
+ ) {
+ Text(stringResource(R.string.sync_dialog_confirm), fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/utils/versionChecker.kt b/app/src/main/java/com/ethran/notable/utils/versionChecker.kt
index 211d8606..49d9e798 100644
--- a/app/src/main/java/com/ethran/notable/utils/versionChecker.kt
+++ b/app/src/main/java/com/ethran/notable/utils/versionChecker.kt
@@ -139,7 +139,7 @@ fun getCurrentVersionName(context: Context): String? {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return packageInfo.versionName
} catch (e: PackageManager.NameNotFoundException) {
- e.printStackTrace()
+ log.e("Package not found: ${e.message}", e)
}
return null
}
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 5ad10af6..a2faccf9 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -121,4 +121,68 @@
Zamaż\n→usuń
Pozycja paska narzędzi
+
+
+ Synchronizacja
+ Synchronizacja WebDAV
+
+
+ Uwaga: \"/notable\" zostanie dodane do ścieżki, aby uporządkować pliki.
+ URL serwera
+ Nazwa użytkownika
+ Hasło
+ https://nextcloud.example.com/remote.php/dav/files/username/
+
+
+ Włącz synchronizację WebDAV
+ Automatyczna synchronizacja co %1$d minut
+ Synchronizuj przy zamykaniu notatek
+
+
+ Zapisz dane logowania
+ Testuj połączenie
+ Testowanie połączenia…
+ Synchronizuj teraz
+ ✓ Zsynchronizowano
+ ✗ Niepowodzenie
+ Wyczyść
+
+
+ Dane logowania zapisane
+ ✓ Połączono pomyślnie
+ ✗ Połączenie nieudane
+ Synchronizacja zakończona pomyślnie
+ Synchronizacja nie powiodła się: %1$s
+ Ostatnia synchronizacja: %1$s
+
+
+ Zsynchronizowano: %1$d, Pobrano: %2$d, Usunięto: %3$d (%4$dms)
+ Błąd w kroku %1$s: %2$s%3$s
+ (możesz spróbować ponownie)
+
+
+ %1$s (%2$d%%)
+
+
+ UWAGA: Operacje zastępowania
+ Używaj tylko podczas konfiguracji nowego urządzenia lub resetowania synchronizacji. Te operacje usuwają dane!
+ ⚠ Zastąp serwer danymi lokalnymi
+ ⚠ Zastąp dane lokalne danymi z serwera
+ Serwer zastąpiony danymi lokalnymi
+ Wymuszony upload nie powiódł się
+ Dane lokalne zastąpione danymi z serwera
+ Wymuszony download nie powiódł się
+
+
+ Zastąpić dane serwera?
+ To USUNIE wszystkie dane na serwerze i zastąpi je danymi lokalnymi z tego urządzenia. Nie można tego cofnąć!\n\nCzy jesteś pewny?
+ Zastąpić dane lokalne?
+ To USUNIE wszystkie lokalne zeszyty i zastąpi je danymi z serwera. Nie można tego cofnąć!\n\nCzy jesteś pewny?
+ Anuluj
+ Potwierdź
+
+
+ Dziennik synchronizacji
+ Brak aktywności synchronizacji
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8d691ce3..ae76e53b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,7 @@
Continuous Zoom
Continuous Stroke Slider
Monochrome mode
+ Open properties on new notebook
Paginate PDF
Preview PDF Pagination
It seems a new version of Notable is available on GitHub!
@@ -104,4 +105,74 @@
Scribble\nto Erase
Toolbar Position
+
+ Sync
+ WebDAV Synchronization
+
+
+ Note: \"/notable\" will be appended to your path to keep files organized.
+ Server URL
+ Username
+ Password
+ https://nextcloud.example.com/remote.php/dav/files/username/
+
+
+ Enable WebDAV Sync
+ Automatic sync every %1$d minutes
+ Sync when closing notes
+ Sync on WiFi only (no mobile data)
+
+
+ Save Credentials
+ Test Connection
+ Testing connection…
+ Sync Now
+ ✓ Synced
+ ✗ Failed
+ Clear
+
+
+ Credentials saved
+ ✓ Connected successfully
+ ✗ Connection failed
+ Sync completed successfully
+ Sync failed: %1$s
+ Last synced: %1$s
+
+
+ Synced: %1$d, Downloaded: %2$d, Deleted: %3$d (%4$dms)
+ Failed at %1$s: %2$s%3$s
+ (can retry)
+
+
+ %1$s (%2$d%%)
+
+
+ CAUTION: Replacement Operations
+ Use these only when setting up a new device or resetting sync. These operations will delete data!
+ ⚠ Replace Server with Local Data
+ ⚠ Replace Local with Server Data
+ Server replaced with local data
+ Force upload failed
+ Local data replaced with server data
+ Force download failed
+
+
+ Replace Server Data?
+ This will DELETE all data on the server and replace it with local data from this device. This cannot be undone!\n\nAre you sure?
+ Replace Local Data?
+ This will DELETE all local notebooks and replace them with data from the server. This cannot be undone!\n\nAre you sure?
+ Cancel
+ Confirm
+
+
+ Connected successfully, but your device clock differs from the server by %1$d seconds. Sync may produce incorrect results until this is corrected.
+
+
+ Not syncing: WiFi-only sync is enabled and you\'re on mobile data. Connect to WiFi or disable the WiFi-only setting.
+
+
+ Sync Log
+ No sync activity yet
+
\ No newline at end of file
diff --git a/docs/webdav-sync-technical.md b/docs/webdav-sync-technical.md
new file mode 100644
index 00000000..f256b068
--- /dev/null
+++ b/docs/webdav-sync-technical.md
@@ -0,0 +1,556 @@
+# WebDAV Sync - Technical Documentation
+
+This document describes the architecture, protocol, data formats, and design decisions of Notable's WebDAV synchronization system. For user-facing setup and usage instructions, see [webdav-sync-user.md](webdav-sync-user.md).
+
+**It was created by AI, and roughly checked for correctness.
+Refer to code for actual implementation.**
+
+## Contents
+
+- [1) Architecture Overview](#1-architecture-overview)
+- [2) Component Overview](#2-component-overview)
+- [3) Sync Protocol](#3-sync-protocol)
+- [4) Data Format Specification](#4-data-format-specification)
+- [5) Conflict Resolution](#5-conflict-resolution)
+- [6) Security Model](#6-security-model)
+- [7) Error Handling and Recovery](#7-error-handling-and-recovery)
+- [8) Integration Points](#8-integration-points)
+- [9) Future Work](#9-future-work)
+- [Appendix: Design Rationale](#appendix-design-rationale)
+
+---
+
+## 1) Architecture Overview
+
+### Design Goal
+
+Enable bidirectional synchronization of notebooks, pages, strokes, images, backgrounds, and folder hierarchy between multiple devices via any standard WebDAV server (Nextcloud, ownCloud, NAS appliances, etc.).
+
+### Why Per-Notebook JSON (Not Database Replication)
+
+The sync system serializes each notebook as a set of JSON files rather than replicating the SQLite database directly. This was a deliberate choice:
+
+- **SQLite is not designed for network concurrency.** Shipping the database file creates split-brain problems when two devices edit different notebooks simultaneously.
+- **Granular failure isolation.** If one notebook fails to sync (corrupt data, network timeout mid-transfer), the rest succeed. A database-level sync is all-or-nothing.
+- **External tooling.** The container format on the server is JSON (human-readable, machine-parseable), with the heavy payload -- stroke point data -- encoded in the existing SB1 binary format (base64-wrapped for JSON transport). This enables external processing (e.g., PyTorch pipelines for handwriting analysis, scripted batch operations) while reusing the same binary stroke encoding the app already uses locally.
+- **Selective sync.** Per-notebook granularity makes it straightforward to add selective sync in the future (sync only some notebooks to a device).
+- **Standard WebDAV compatibility.** The protocol only requires GET, PUT, DELETE, MKCOL, HEAD, and PROPFIND -- operations that every WebDAV server supports. No server-side logic or database is needed.
+
+---
+
+## 2) Component Overview
+
+All sync code lives in `com.ethran.notable.sync`. The components and their responsibilities:
+
+```
+┌─────────────────────────────────────────────────────┐
+│ SyncEngine │
+│ Orchestrates the full sync flow: folder sync, │
+│ deletion propagation, notebook upload/download, │
+│ conflict resolution, state machine, progress. │
+├───────────┬──────────────┬──────────────────────────┤
+│ │ │ │
+│ WebDAVClient Serializers Infrastructure │
+│ ─────────── ────────── ────────────── │
+│ OkHttp-based NotebookSerializer CredentialMgr │
+│ PUT/GET/DEL FolderSerializer SyncWorker │
+│ MKCOL/PROPFIND DeletionsSerializer SyncScheduler │
+│ HEAD/streaming SyncLogger │
+│ ConnectChecker │
+└─────────────────────────────────────────────────────┘
+```
+
+| File | Role |
+|------|------|
+| [`SyncEngine.kt`](../app/src/main/java/com/ethran/notable/sync/SyncEngine.kt) | Core orchestrator. Full sync flow, per-notebook sync, deletion upload, force upload/download. State machine (`SyncState`) and mutex for concurrency control. |
+| [`WebDAVClient.kt`](../app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt) | HTTP/WebDAV operations. PROPFIND XML parsing. Connection testing. Streaming downloads. |
+| [`NotebookSerializer.kt`](../app/src/main/java/com/ethran/notable/sync/NotebookSerializer.kt) | Serializes/deserializes notebooks, pages, strokes, and images to/from JSON. Stroke points are embedded as base64-encoded [SB1 binary](database-structure.md) data. |
+| [`FolderSerializer.kt`](../app/src/main/java/com/ethran/notable/sync/FolderSerializer.kt) | Serializes/deserializes the folder hierarchy to/from `folders.json`. |
+| [`DeletionsSerializer.kt`](../app/src/main/java/com/ethran/notable/sync/DeletionsSerializer.kt) | Deserializes the legacy `deletions.json` format. Used only by the one-time migration that converts old entries to tombstone files; not written by new code. |
+| [`SyncWorker.kt`](../app/src/main/java/com/ethran/notable/sync/SyncWorker.kt) | `CoroutineWorker` for WorkManager integration. Checks connectivity and credentials before delegating to `SyncEngine`. |
+| [`SyncScheduler.kt`](../app/src/main/java/com/ethran/notable/sync/SyncScheduler.kt) | Schedules/cancels periodic sync via WorkManager. |
+| [`CredentialManager.kt`](../app/src/main/java/com/ethran/notable/sync/CredentialManager.kt) | Stores WebDAV credentials in `EncryptedSharedPreferences` (AES-256-GCM). |
+| [`ConnectivityChecker.kt`](../app/src/main/java/com/ethran/notable/sync/ConnectivityChecker.kt) | Queries Android `ConnectivityManager` for network/WiFi availability. |
+| [`SyncLogger.kt`](../app/src/main/java/com/ethran/notable/sync/SyncLogger.kt) | Maintains a ring buffer of recent log entries (exposed as `StateFlow`) for the sync UI. |
+
+---
+
+## 3) Sync Protocol
+
+### 3.1 Full Sync Flow (`syncAllNotebooks`)
+
+A full sync executes the following steps in order. A coroutine `Mutex` prevents concurrent sync operations on a single device (see section 7.2 for multi-device concurrency).
+
+```
+1. INITIALIZE
+ ├── Load AppSettings and credentials
+ ├── Construct WebDAVClient
+ └── Ensure /notable/ and /notable/notebooks/ exist on server (MKCOL)
+
+2. SYNC FOLDERS
+ ├── GET /notable/folders.json (if exists)
+ ├── Merge: for each folder, keep the version with the later updatedAt
+ ├── Upsert merged folders into local Room database
+ └── PUT /notable/folders.json (merged result)
+
+3. APPLY REMOTE DELETIONS
+ ├── PROPFIND /notable/deletions/ (Depth 1) → list of tombstone files with lastModified
+ ├── For each tombstone (filename = deleted notebook UUID):
+ │ ├── If local notebook was modified AFTER the tombstone's lastModified → SKIP (resurrection)
+ │ └── Otherwise → delete local notebook
+ └── Return tombstonedIds set for use in later steps
+
+4. SYNC EXISTING LOCAL NOTEBOOKS
+ ├── Snapshot local notebook IDs (the "pre-download set")
+ └── For each local notebook:
+ ├── HEAD /notable/notebooks/{id}/manifest.json
+ ├── If remote exists:
+ │ ├── GET manifest.json, parse updatedAt
+ │ ├── Compare timestamps (with ±1s tolerance):
+ │ │ ├── Local newer → upload notebook
+ │ │ ├── Remote newer → download notebook
+ │ │ └── Within tolerance → skip
+ │ └── (end comparison)
+ └── If remote doesn't exist → upload notebook
+
+5. DOWNLOAD NEW NOTEBOOKS FROM SERVER
+ ├── PROPFIND /notable/notebooks/ (Depth 1) → list of notebook directory UUIDs
+ ├── Filter out: already-local, already-deleted, previously-synced-then-locally-deleted
+ └── For each new notebook ID → download notebook
+
+6. DETECT AND UPLOAD LOCAL DELETIONS
+ ├── Compare syncedNotebookIds (from last sync) against pre-download snapshot
+ ├── Missing IDs = locally deleted notebooks
+ ├── For each: DELETE /notable/notebooks/{id}/ on server
+ └── PUT zero-byte file to /notable/deletions/{id} (tombstone for other devices)
+
+7. FINALIZE
+ ├── Update syncedNotebookIds = current set of all local notebook IDs
+ └── Persist to AppSettings
+```
+
+### 3.2 Per-Notebook Upload
+
+Conflict detection is at the **notebook level** (manifest `updatedAt`). Individual pages are uploaded as separate files, but if two devices have edited different pages of the same notebook, the device with the newer `updatedAt` wins the entire notebook (see section 5.6).
+
+```
+uploadNotebook(notebook):
+ 1. MKCOL /notable/notebooks/{id}/pages/
+ 2. MKCOL /notable/notebooks/{id}/images/
+ 3. MKCOL /notable/notebooks/{id}/backgrounds/
+ 4. PUT /notable/notebooks/{id}/manifest.json (serialized notebook metadata)
+ 5. For each page:
+ a. Serialize page JSON (strokes embedded as base64-encoded SB1 binary)
+ b. PUT /notable/notebooks/{id}/pages/{pageId}.json
+ c. For each image on the page:
+ - If local file exists and not already on server → PUT to images/
+ d. If page has a custom background (not native template):
+ - If local file exists and not already on server → PUT to backgrounds/
+```
+
+### 3.3 Per-Notebook Download
+
+```
+downloadNotebook(notebookId):
+ 1. GET /notable/notebooks/{id}/manifest.json → parse to Notebook
+ 2. Upsert Notebook into local Room database (preserving remote timestamp)
+ 3. For each pageId in manifest.pageIds:
+ a. GET /notable/notebooks/{id}/pages/{pageId}.json → parse to (Page, Strokes, Images)
+ b. For each image referenced:
+ - GET from images/ → save to local /Documents/notabledb/images/
+ - Update image URI to local absolute path
+ c. If custom background:
+ - GET from backgrounds/ → save to local backgrounds folder
+ d. If page already exists locally:
+ - Delete old strokes and images from Room
+ - Update page
+ e. If page is new:
+ - Create page in Room
+ f. Insert strokes and images
+```
+
+### 3.4 Single-Notebook Sync (`syncNotebook`)
+
+Used for sync-on-close (triggered when the user closes the editor). Follows the same timestamp-comparison logic as step 4 of the full sync, but operates on a single notebook without the full deletion/discovery flow.
+
+### 3.5 Deletion Propagation (`uploadDeletion`)
+
+When a notebook is deleted locally, a targeted operation can immediately propagate the deletion to the server without running a full sync:
+
+1. DELETE the notebook's directory from server.
+2. PUT a zero-byte file to `/notable/deletions/{id}` (the server's own `lastModified` on this file serves as the deletion timestamp for other devices' conflict resolution).
+3. Remove notebook ID from `syncedNotebookIds`.
+
+---
+
+## 4) Data Format Specification
+
+### 4.1 Server Directory Structure
+
+```
+/notable/ ← Appended to user's server URL
+├── folders.json ← Complete folder hierarchy
+├── deletions/ ← Deletion tracking (zero-byte files)
+│ └── {uuid} ← One per deleted notebook; server lastModified = deletion time
+└── notebooks/
+ └── {uuid}/ ← One directory per notebook, named by UUID
+ ├── manifest.json ← Notebook metadata
+ ├── pages/
+ │ └── {uuid}.json ← Page data with embedded strokes
+ ├── images/
+ │ └── {filename} ← Image files referenced by pages
+ └── backgrounds/
+ └── {filename} ← Custom background images
+```
+
+### 4.2 manifest.json
+
+```json
+{
+ "version": 1,
+ "notebookId": "550e8400-e29b-41d4-a716-446655440000",
+ "title": "My Notebook",
+ "pageIds": ["page-uuid-1", "page-uuid-2"],
+ "openPageId": "page-uuid-1",
+ "parentFolderId": "folder-uuid-or-null",
+ "defaultBackground": "blank",
+ "defaultBackgroundType": "native",
+ "linkedExternalUri": null,
+ "createdAt": "2025-06-15T10:30:00Z",
+ "updatedAt": "2025-12-20T14:22:33Z",
+ "serverTimestamp": "2025-12-21T08:00:00Z"
+}
+```
+
+- `version`: Schema version for forward compatibility. Currently `1`.
+- `pageIds`: Ordered list -- defines page ordering within the notebook.
+- `serverTimestamp`: Set at serialization time. Used for sync comparison.
+- All timestamps are ISO 8601 UTC.
+
+### 4.3 Page JSON (`pages/{uuid}.json`)
+
+```json
+{
+ "version": 1,
+ "id": "page-uuid",
+ "notebookId": "notebook-uuid",
+ "background": "blank",
+ "backgroundType": "native",
+ "parentFolderId": null,
+ "scroll": 0,
+ "createdAt": "2025-06-15T10:30:00Z",
+ "updatedAt": "2025-12-20T14:22:33Z",
+ "strokes": [
+ {
+ "id": "stroke-uuid",
+ "size": 3.0,
+ "pen": "BALLPOINT",
+ "color": -16777216,
+ "maxPressure": 4095,
+ "top": 100.0,
+ "bottom": 200.0,
+ "left": 50.0,
+ "right": 300.0,
+ "pointsData": "U0IBCgAAAA...",
+ "createdAt": "2025-12-20T14:22:00Z",
+ "updatedAt": "2025-12-20T14:22:33Z"
+ }
+ ],
+ "images": [
+ {
+ "id": "image-uuid",
+ "x": 0,
+ "y": 0,
+ "width": 800,
+ "height": 600,
+ "uri": "images/abc123.jpg",
+ "createdAt": "2025-12-20T14:22:00Z",
+ "updatedAt": "2025-12-20T14:22:33Z"
+ }
+ ]
+}
+```
+
+- `strokes[].pointsData`: Base64-encoded SB1 binary format. See [database-structure.md](database-structure.md) section 3 for the full SB1 specification. This is the same binary format used in the local Room database, base64-wrapped for JSON transport.
+- `strokes[].color`: ARGB integer (e.g., `-16777216` = opaque black).
+- `strokes[].pen`: Enum name from the `Pen` type (BALLPOINT, FOUNTAIN, PENCIL, etc.).
+- `images[].uri`: Relative path on the server (e.g., `images/filename.jpg`). Converted to/from absolute local paths during upload/download.
+- `notebookId`: May be `null` for Quick Pages (standalone pages not belonging to a notebook).
+
+### 4.4 folders.json
+
+```json
+{
+ "version": 1,
+ "folders": [
+ {
+ "id": "folder-uuid",
+ "title": "My Folder",
+ "parentFolderId": null,
+ "createdAt": "2025-06-15T10:30:00Z",
+ "updatedAt": "2025-12-20T14:22:33Z"
+ }
+ ],
+ "serverTimestamp": "2025-12-21T08:00:00Z"
+}
+```
+
+- `parentFolderId`: References another folder's `id` for nesting, or `null` for root-level folders.
+- Folder hierarchy must be synced before notebooks because notebooks reference `parentFolderId`.
+
+### 4.5 Tombstone Files (`deletions/{uuid}`)
+
+Each deleted notebook has a zero-byte file at `/notable/deletions/{notebook-uuid}`. The file has no content; the server's own `lastModified` timestamp on the file provides the deletion time used for conflict resolution (section 5.3).
+
+**Why tombstones instead of a shared `deletions.json`?** Two devices syncing simultaneously would both read `deletions.json`, append their entry, and write back — the second writer clobbers the first. With tombstones, each deletion is an independent PUT to a unique path, so there is nothing to race over.
+
+**Migration**: A one-time migration in `SyncEngine.migrateDeletionsJsonToTombstones()` reads any existing `deletions.json` from the server, creates tombstone files for each entry, and then deletes the old file. After migration, `deletions.json` is not written by new code.
+
+### 4.6 JSON Configuration
+
+All serializers use `kotlinx.serialization` with:
+- `prettyPrint = true`: Human-readable output, debuggable on the server.
+- `ignoreUnknownKeys = true`: Forward compatibility. If a future version adds fields, older clients can still parse the JSON without crashing.
+
+---
+
+## 5) Conflict Resolution
+
+### 5.1 Strategy: Last-Writer-Wins with Resurrection
+
+The sync system uses **timestamp-based last-writer-wins** at the notebook level. This is a deliberate simplicity tradeoff:
+
+- **Simpler than CRDT or operational transform.** These are powerful but add substantial complexity and are difficult to get right for a handwriting/drawing app where strokes are the atomic unit.
+- **Appropriate for the use case.** Most Notable users have one or two devices. Simultaneous editing of the same notebook on two devices is rare. When it does happen, the most recent edit is almost always the one the user wants.
+- **Predictable behavior.** Users can reason about "I edited this last, so my version wins" without understanding distributed systems theory.
+
+### 5.2 Timestamp Comparison
+
+When both local and remote versions of a notebook exist:
+
+```
+diffMs = local.updatedAt - remote.updatedAt
+
+if diffMs > +1000ms → local is newer → upload
+if diffMs < -1000ms → remote is newer → download
+if |diffMs| <= 1000ms → within tolerance → skip (considered equal)
+```
+
+The 1-second tolerance exists because timestamps pass through ISO 8601 serialization (which truncates to seconds) and through different system clocks. Without tolerance, rounding artifacts would cause spurious upload/download cycles.
+
+### 5.3 Deletion vs. Edit Conflicts
+
+The most dangerous conflict in any sync system is: device A deletes a notebook while device B (offline) edits it. Without careful handling, the edit is silently lost.
+
+Notable handles this with **tombstone-based resurrection**:
+
+1. When a notebook is deleted, a zero-byte tombstone file is PUT to `/notable/deletions/{id}`. The server records a `lastModified` timestamp on the tombstone at the time of the PUT.
+2. During sync, when applying remote tombstones:
+ - If the local notebook's `updatedAt` is **after** the tombstone's `lastModified`, the notebook is **resurrected** (not deleted locally, and it will be re-uploaded during the upload phase; the tombstone is deleted from the server).
+ - If the local notebook's `updatedAt` is **before** the tombstone's `lastModified`, the notebook is deleted locally (safe to remove).
+3. This ensures that edits made after a deletion are never silently discarded.
+
+**Prior art**: This is the same technique used by [Saber](https://github.com/saber-notes/saber) (`lib/data/nextcloud/saber_syncer.dart`), which treats any zero-byte remote file as a tombstone. The key property is that tombstones are independent per-notebook files, so two devices can write tombstones simultaneously without racing over a shared file.
+
+### 5.4 Folder Merge
+
+Folders use a simpler per-folder last-writer-wins merge:
+- All remote folders are loaded into a map.
+- Local folders are merged in: if a local folder has a later `updatedAt` than its remote counterpart, the local version wins.
+- The merged set is written to both the local database and the server.
+
+### 5.5 Move Operations
+
+- **Notebook moved to a different folder**: Updates `parentFolderId` on the notebook, which bumps `updatedAt`. The manifest is re-uploaded on the next sync, propagating the move.
+- **Pages rearranged within a notebook**: Updates the `pageIds` order in the manifest, which bumps `updatedAt`. Same mechanism -- manifest re-uploads on next sync.
+
+### 5.6 Local Deletion Detection
+
+Detecting that a notebook was deleted locally (as opposed to never existing) requires comparing the current set of local notebook IDs against the set from the last successful sync (`syncedNotebookIds` in AppSettings):
+
+```
+locallyDeleted = syncedNotebookIds - currentLocalNotebookIds
+```
+
+This comparison uses a **pre-download snapshot** of local notebook IDs -- taken before downloading new notebooks from the server. This is critical: without it, a newly downloaded notebook would appear "new" in the current set and would not be in `syncedNotebookIds`, causing it to be misidentified as a local deletion.
+
+### 5.7 Known Limitations
+
+- **Page-level conflicts are not merged.** If two devices edit different pages of the same notebook, the entire notebook is overwritten by the newer version. Stroke-level or page-level merging is a potential future enhancement.
+- **No conflict UI.** There is no mechanism to present both versions to the user and let them choose. Last-writer-wins is applied automatically.
+- **Folder deletion is not cascaded across devices.** Deleting a folder locally does not propagate to other devices (only notebook deletions are tracked via tombstones).
+- **`folders.json` writes are not atomic.** This shared file is updated via read-modify-write with no server-side locking. If two devices sync simultaneously, one device's write can clobber the other's merge. The next sync will self-heal, but a folder rename or deletion could be lost in the narrow window. Notebook deletions do not have this problem — they use per-notebook tombstones. ETag-based optimistic locking (see section 9) would eliminate the `folders.json` race.
+- **Depends on reasonably synchronized device clocks.** Timestamp comparison is the foundation of conflict resolution. If two devices have significantly different clock settings, the wrong version may win. This is mitigated by the clock skew detection described in 5.8, which blocks sync when the device clock differs from the server by more than 30 seconds.
+
+### 5.8 Clock Skew Detection
+
+Because the sync system relies on `updatedAt` timestamps set by each device's local clock, clock disagreements between devices can cause the wrong version to win during conflict resolution. For example, if Device A's clock is 5 minutes ahead, its edits will always appear "newer" even if Device B edited more recently.
+
+**Validation:** Before every sync (both full sync and single-notebook sync-on-close), the engine makes a HEAD request to the WebDAV server and reads the HTTP `Date` response header. This is compared against the device's `System.currentTimeMillis()` to compute the skew.
+
+**Threshold:** If the absolute skew exceeds 30 seconds (`CLOCK_SKEW_THRESHOLD_MS`), the sync is aborted with a `CLOCK_SKEW` error. This threshold is generous enough to tolerate normal NTP drift but strict enough to catch misconfigured clocks.
+
+**Escape hatch:** Force upload and force download operations are **not** gated by clock skew detection. These are explicit user actions that bypass normal sync logic entirely, so timestamp comparison is irrelevant -- the user is choosing which side wins wholesale.
+
+**UI feedback:** The settings "Test Connection" button also checks clock skew. If the connection succeeds but skew exceeds the threshold, a warning is displayed telling the user how many seconds their clock differs from the server.
+
+---
+
+## 6) Security Model
+
+### 6.1 Credential Storage
+
+Credentials are stored using Android's `EncryptedSharedPreferences` (from `androidx.security:security-crypto`):
+
+- **Master key**: AES-256-GCM, managed by Android Keystore.
+- **Key encryption**: AES-256-SIV (deterministic authenticated encryption for preference keys).
+- **Value encryption**: AES-256-GCM (authenticated encryption for preference values).
+- Credentials are stored separately from the main app database (`KvProxy`), ensuring they are always encrypted at rest regardless of device encryption state.
+
+### 6.2 Transport Security
+
+- The WebDAV client communicates over HTTPS (strongly recommended in user documentation).
+- HTTP URLs are accepted but not recommended. The client does not enforce HTTPS -- this is left to the user's discretion since some users run WebDAV on local networks.
+- OkHttp handles TLS certificate validation using the system trust store.
+
+### 6.3 Logging
+
+- `SyncLogger` never logs credentials or authentication headers.
+- Debug logging of PROPFIND responses is truncated to 1500 characters to prevent sensitive directory listings from filling logs.
+
+---
+
+## 7) Error Handling and Recovery
+
+### 7.1 Error Types
+
+```kotlin
+enum class SyncError {
+ NETWORK_ERROR, // IOException - connection failed, timeout, DNS resolution
+ AUTH_ERROR, // Credentials missing or invalid
+ CONFIG_ERROR, // Settings missing or sync disabled
+ CLOCK_SKEW, // Device clock differs from server by >30s (see 5.8)
+ SYNC_IN_PROGRESS, // Another sync is already running (mutex held)
+ UNKNOWN_ERROR // Catch-all for unexpected exceptions
+}
+```
+
+### 7.2 Concurrency Control
+
+A companion-object-level `Mutex` prevents concurrent sync operations across all `SyncEngine` instances on a single device. If `syncAllNotebooks()` is called while a sync is already running, it returns immediately with `SyncResult.Failure(SYNC_IN_PROGRESS)`.
+
+There is no cross-device locking -- WebDAV does not provide atomic multi-file transactions. See the concurrency note in section 5.7.
+
+### 7.3 Failure Isolation
+
+Failures are isolated at the notebook level:
+- If a single notebook fails to upload or download, the error is logged and sync continues with the remaining notebooks.
+- If a single page fails to download within a notebook, the error is logged and the remaining pages are still processed.
+- Only top-level failures (network unreachable, credentials invalid, server directory structure creation failed) abort the entire sync.
+
+### 7.4 Retry Strategy (Background Sync)
+
+`SyncWorker` (WorkManager) implements retry with the following policy:
+- **Network unavailable**: Return `Result.retry()` (WorkManager will back off and retry).
+- **Sync already in progress**: Return `Result.success()` (not an error -- another sync is handling it).
+- **Network error during sync**: Retry up to 3 attempts, then fail.
+- **Other errors**: Retry up to 3 attempts, then fail.
+- WorkManager's exponential backoff handles retry timing.
+
+### 7.5 WebDAV Idempotency
+
+The WebDAV client handles standard server responses that are not errors:
+- `MKCOL` returning 405 (Method Not Allowed) is treated as success -- per RFC 4918, this means the collection already exists. This is only accepted on `MKCOL`; a 405 on any other operation is treated as an error.
+- `DELETE` returning 404 (Not Found) is treated as success -- the resource is already gone.
+- Both operations are thus idempotent and safe to retry.
+
+### 7.6 State Machine
+
+Sync state is exposed as a `StateFlow` for UI observation:
+
+```
+Idle → Syncing(step, progress, details) → Success(summary) → Idle
+ → Error(error, step, canRetry)
+```
+
+- `Syncing` includes a `SyncStep` enum and float progress (0.0-1.0) for progress indication.
+- `Success` auto-resets to `Idle` after 3 seconds.
+- `Error` persists until the next sync attempt.
+
+---
+
+## 8) Integration Points
+
+### 8.1 WorkManager (Background Sync)
+
+`SyncScheduler` enqueues a `PeriodicWorkRequest` with:
+- Default interval: 15 minutes (WorkManager enforces a hard minimum of 15 minutes).
+- Network constraint: `NetworkType.CONNECTED` (won't run without network).
+- Policy: `ExistingPeriodicWorkPolicy.KEEP` (doesn't restart if already scheduled).
+
+### 8.2 Settings
+
+Sync configuration lives in `AppSettings.syncSettings`:
+- `syncEnabled`: Master toggle.
+- `serverUrl`: WebDAV endpoint.
+- `syncedNotebookIds`: Set of notebook UUIDs from the last successful sync (used for local deletion detection).
+- Credentials are stored separately in `CredentialManager` (not in AppSettings).
+
+### 8.3 Editor Integration (Sync on Close)
+
+`EditorControlTower` can trigger `syncNotebook(notebookId)` when a note is closed, providing near-real-time sync without waiting for the periodic schedule.
+
+### 8.4 Dependencies
+
+| Dependency | Version | Purpose |
+|-----------|---------|---------|
+| `com.squareup.okhttp3:okhttp` | 4.12.0 | HTTP client for all WebDAV operations |
+| `androidx.security:security-crypto` | 1.1.0-alpha06 | Encrypted credential storage |
+| `org.jetbrains.kotlinx:kotlinx-serialization-json` | 1.9.0 | JSON serialization/deserialization |
+| `androidx.work:work-runtime-ktx` | (project version) | Background sync scheduling |
+
+---
+
+## 9) Future Work
+
+Potential enhancements beyond the current implementation, roughly ordered by impact:
+
+1. **ETag-based optimistic locking for `folders.json`.** This shared file is updated via read-modify-write with no coordination between devices. Using `If-Match` on PUT (and re-reading on 412 Precondition Failed) would eliminate the concurrent-write race described in section 5.7. Most WebDAV servers (including Nextcloud) return strong ETags on all resources. Tombstones already solved this problem for notebook deletions; `folders.json` is the last remaining shared mutable file.
+2. **ETag-based change detection.** Extend ETags to notebook manifests: store the ETag from each GET, send `If-None-Match` on the next sync -- a 304 avoids downloading the full manifest. This would also make clock skew detection unnecessary for change detection.
+3. **Page-level sync granularity.** Compare and sync individual pages rather than whole notebooks to reduce bandwidth and improve conflict handling for multi-page notebooks.
+4. **Stroke-level merge.** When two devices edit different pages of the same notebook, merge non-overlapping changes instead of last-writer-wins at the notebook level.
+5. **Conflict UI.** Present both local and remote versions when a conflict is detected and let the user choose.
+6. **Selective sync.** Allow users to choose which notebooks sync to which devices.
+7. **Compression.** Gzip large JSON files before upload to reduce bandwidth.
+8. **Quick Pages sync.** Pages with `notebookId = null` (standalone pages not in any notebook) are not currently synced.
+9. **Device screen size scaling.** Notes created on one Boox tablet size may need coordinate scaling on a different model.
+
+---
+
+## Appendix: Design Rationale
+
+### A.1 Why Not CouchDB / Syncthing / Other Sync Frameworks?
+
+Notable targets Onyx Boox e-ink tablets, which are locked-down Android devices. The sync solution must:
+
+1. Work with servers the user already owns (Nextcloud is extremely common in this community).
+2. Require no server-side component installation.
+3. Run within a standard Android app without root or sideloaded services.
+4. Use only HTTP/HTTPS for network communication (no custom protocols, no peer-to-peer).
+
+WebDAV meets all four constraints. CouchDB would require server installation. Syncthing requires a background service and open ports. SFTP is not universally available. WebDAV is the lowest common denominator that actually works for this user base.
+
+### A.2 Why a Custom WebDAV Client
+
+[`WebDAVClient.kt`](../app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt) implements WebDAV operations directly on OkHttp rather than using an existing library. Evaluated alternatives:
+
+- **Sardine** (most popular Java WebDAV library): Depends on Apache HttpClient, removed from the Android SDK in API 23. The `android-sardine` fork is unmaintained since 2019.
+- **jackrabbit-webdav**: Heavyweight JCR dependency (~2MB+), also depends on Apache HttpClient.
+- **Milton**: Server-side WebDAV framework, not a client.
+
+Notable's WebDAV needs are narrow (PUT, GET, DELETE, MKCOL, PROPFIND, HEAD), so a purpose-built implementation on OkHttp (already a project dependency) is smaller than any of the above with zero added transitive dependencies. PROPFIND XML responses are parsed with Android's built-in `XmlPullParser`.
+
+---
+
+**Version**: 1.2
+**Last Updated**: 2026-03-06
diff --git a/docs/webdav-sync-user.md b/docs/webdav-sync-user.md
new file mode 100644
index 00000000..07128c72
--- /dev/null
+++ b/docs/webdav-sync-user.md
@@ -0,0 +1,258 @@
+# WebDAV Sync - User Guide
+
+## Overview
+
+Notable supports WebDAV synchronization to keep your notebooks, pages, and drawings in sync across multiple devices. WebDAV is a standard protocol that works with many cloud storage providers and self-hosted servers.
+
+## What Gets Synced?
+
+- **Notebooks**: All your notebooks and their metadata
+- **Pages**: Individual pages within notebooks
+- **Strokes**: Your drawings and handwriting (stored in efficient SB1 binary format)
+- **Images**: Embedded images in your notes
+- **Backgrounds**: Custom page backgrounds
+- **Folders**: Your folder organization structure
+
+## Prerequisites
+
+You'll need access to a WebDAV server. Common options include:
+
+### Popular WebDAV Providers
+
+1. **Nextcloud** (Recommended for self-hosting)
+ - Free and open source
+ - Full control over your data
+ - URL format: `https://your-nextcloud.com/remote.php/dav/files/username/` (some installations may require the ownCloud format seen below)
+
+2. **ownCloud**
+ - Similar to Nextcloud
+ - URL format: `https://your-owncloud.com/remote.php/webdav/`
+
+3. **Box.com**
+ - Commercial cloud storage with WebDAV support
+ - URL format: `https://dav.box.com/dav/`
+
+4. **Other providers**
+ - Many NAS devices (Synology, QNAP) support WebDAV
+ - Some web hosting providers offer WebDAV access
+
+## Setup Instructions
+
+### 1. Get Your WebDAV Credentials
+
+From your WebDAV provider, you'll need:
+- **Server URL**: The full WebDAV endpoint URL
+- **Username**: Your account username
+- **Password**: Your account password or app-specific password
+
+**Important**: Notable will automatically append `/notable` to your server URL to keep your data organized. For example:
+- You enter: `https://nextcloud.example.com/remote.php/dav/files/username/`
+- Notable creates: `https://nextcloud.example.com/remote.php/dav/files/username/notable/`
+
+This prevents your notebooks from cluttering the root of your WebDAV storage.
+
+#### Using Two-Factor Authentication (2FA)
+
+If your Nextcloud account has two-factor authentication enabled, your regular password will not work for WebDAV. You'll need to create an app-specific password:
+
+1. Log in to Nextcloud via your browser
+2. Go to **Settings** → **Security**
+3. Under **Devices & sessions**, click **Create new app password**
+4. Give it a name (e.g., "Notable")
+5. Nextcloud will generate a username and password for this app
+6. Use these generated credentials (not your regular login) when configuring Notable
+
+Other WebDAV providers with 2FA may have a similar app password mechanism -- check your provider's documentation.
+
+### 2. Configure Notable
+
+1. Open Notable
+2. Go to **Settings** (gear wheel icon)
+3. Select the **Sync** tab
+4. Enter your WebDAV credentials:
+ - **Server URL**: Your WebDAV endpoint URL
+ - **Username**: Your account username
+ - **Password**: Your account password
+5. Click **Save Credentials**
+
+### 3. Test Your Connection
+
+1. Click the **Test Connection** button
+2. Wait for the test to complete
+3. You should see "✓ Connected successfully"
+4. If connection fails, double-check your credentials and URL
+
+### 4. Enable Sync
+
+Toggle **Enable WebDAV Sync** to start syncing your notebooks.
+
+## Sync Options
+
+### Manual Sync
+Click **Sync Now** to manually trigger synchronization. This will:
+- Upload any local changes to the server
+- Download any changes from other devices
+- Resolve conflicts intelligently
+ - Generally, last writer wins, including after deletions. If you make changes to a notebook after it has been deleted on any device, your notebook will be "resurrected" and re-created with the new changes.
+
+### Automatic Sync
+Enable **Automatic sync every X minutes** to sync periodically in the background.
+
+### Sync on Note Close
+Enable **Sync when closing notes** to automatically sync whenever you close a page. This ensures your latest changes are uploaded immediately.
+
+## Advanced Features
+
+### Force Operations (Use with Caution!)
+
+Located under **CAUTION: Replacement Operations**:
+
+- **Replace Server with Local Data**: Deletes everything on the server and uploads all local notebooks. Use this if the server has incorrect data.
+
+- **Replace Local with Server Data**: Deletes all local notebooks and downloads everything from the server. Use this if your local data is corrupted.
+
+**Warning**: These operations are destructive and cannot be undone! Make sure you know which copy of your data is correct before using these.
+
+## Conflict Resolution
+
+Notable handles conflicts intelligently:
+
+### Notebook Deletion Conflicts
+If a notebook is deleted on one device but modified on another device (while offline), Notable will **resurrect** the modified notebook instead of deleting it. This prevents accidental data loss.
+
+### Timestamp-Based Sync
+Notable uses timestamps to determine which version is newer:
+- If local changes are newer → Upload to server
+- If server changes are newer → Download to device
+- Equal timestamps → No sync needed
+
+## Sync Log
+
+The **Sync Log** section shows real-time information about sync operations:
+- Which notebooks were synced
+- Upload/download counts
+- Any errors that occurred
+- Timestamps and performance metrics
+
+Click **Clear** to clear the log.
+
+## Troubleshooting
+
+### Connection Failed
+
+**Problem**: Test connection fails with "✗ Connection failed"
+
+**Solutions**:
+1. Verify your server URL is correct
+2. Check username and password are accurate
+3. Ensure you have internet connectivity
+4. Check if your server requires HTTPS (not HTTP)
+5. Try accessing the WebDAV URL in a web browser
+6. Check if your server requires an app-specific password (common with 2FA)
+
+### Sync Fails
+
+**Problem**: Sync operation fails or shows errors in the log
+
+**Solutions**:
+1. Check the Sync Log for specific error messages
+2. Verify you have sufficient storage space on the server
+3. Try **Test Connection** again to ensure credentials are still valid
+4. Check if the `/notable` directory exists on your server and is writable
+5. Try force-downloading to get a fresh copy from the server
+
+### Notebooks Not Appearing on Other Device
+
+**Problem**: Synced on one device but not showing on another
+
+**Solutions**:
+1. Make sure both devices have sync enabled
+2. Manually trigger **Sync Now** on both devices
+3. Check the Sync Log on both devices for errors
+4. Verify both devices are using the same server URL and credentials
+5. Check the server directly (via web interface) to see if files were uploaded
+
+### Very Slow Sync
+
+**Problem**: Sync takes a long time to complete
+
+**Solutions**:
+1. This is normal for first sync with many notebooks
+2. Subsequent syncs are incremental and much faster
+3. Check your internet connection speed
+4. Consider reducing auto-sync frequency
+5. Large images or backgrounds may take longer to upload
+
+### "Too Many Open Connections" Error
+
+**Problem**: Sync fails with connection pool errors
+
+**Solutions**:
+1. Wait a few minutes and try again
+2. Close and reopen the app
+3. This usually resolves automatically
+
+## Data Format
+
+Notable stores your data on the WebDAV server in the following structure:
+
+```
+/notable/
+├── folders.json # Folder hierarchy
+├── deletions/ # Tracks deleted notebooks (zero-byte files)
+│ └── {notebook-id}
+└── notebooks/
+ ├── {notebook-id-1}/
+ │ ├── manifest.json # Notebook metadata
+ │ ├── pages/
+ │ │ └── {page-id}.json
+ │ ├── images/
+ │ │ └── {image-file}
+ │ └── backgrounds/
+ │ └── {background-file}
+ └── {notebook-id-2}/
+ └── ...
+```
+
+### Efficient Storage
+
+- **Strokes**: Stored as base64-encoded SB1 binary format with LZ4 compression for minimal file size
+- **Images**: Stored as-is in their original format
+- **JSON files**: Human-readable metadata
+
+## Privacy & Security
+
+- **Credentials**: Stored securely using Android's `EncryptedSharedPreferences` (AES-256-GCM, backed by Android Keystore)
+- **Data in transit**: Uses HTTPS for secure communication (recommended)
+- **Data at rest**: Depends on your WebDAV provider's security
+- **No third-party cloud service**: Your data only goes to the WebDAV server you specify
+
+## Best Practices
+
+1. **Use HTTPS**: Always use `https://` URLs for security
+2. **Regular syncs**: Enable automatic sync to avoid conflicts
+3. **Backup**: Consider backing up your WebDAV storage separately
+4. **Test first**: Use Test Connection before enabling sync
+5. **Monitor logs**: Check Sync Log occasionally for any issues
+6. **Dedicated folder**: The `/notable` subdirectory keeps things organized
+
+## Getting Help
+
+If you encounter issues:
+
+1. Check the Sync Log for error details
+2. Verify your WebDAV server is accessible
+3. Try the troubleshooting steps above
+4. Report issues at: https://github.com/Ethran/notable/issues
+
+## Technical Details
+
+For developers interested in how sync works internally, see:
+- [WebDAV Sync Technical Documentation](webdav-sync-technical.md) - Architecture, sync protocol, data formats, conflict resolution
+- [Database Structure](database-structure.md) - Data storage formats including SB1
+- [File Structure](file-structure.md) - Local file organization
+
+---
+
+**Version**: 1.1
+**Last Updated**: 2026-03-06