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/app/build.gradle b/app/build.gradle
index da0c5ae2..2082fb60 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -196,6 +196,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 @@
+
+
+ @Inject
+ lateinit var credentialManager: dagger.Lazy
+
@Inject
lateinit var snackDispatcher: SnackDispatcher
@@ -99,7 +104,6 @@ class MainActivity : ComponentActivity() {
SCREEN_WIDTH = applicationContext.resources.displayMetrics.widthPixels
SCREEN_HEIGHT = applicationContext.resources.displayMetrics.heightPixels
-
val snackState = SnackState()
setContent {
@@ -119,6 +123,9 @@ class MainActivity : ComponentActivity() {
.registerComponentCallbacks(this@MainActivity.applicationContext)
editorSettingCacheManager.get().init()
}
+ restorePeriodicSyncSchedule()
+ // Trigger initial sync on app startup (fails silently if offline)
+ triggerInitialSync()
}
isInitialized = true
}
@@ -141,6 +148,28 @@ class MainActivity : ComponentActivity() {
}
}
+
+ private fun triggerInitialSync() {
+ try {
+ val settings = credentialManager.get().settings.value
+ if (settings.syncEnabled) {
+ Log.i(TAG, "Triggering one-time sync on app startup via WorkManager")
+ SyncScheduler.triggerImmediateSync(applicationContext)
+ }
+ } catch (e: Exception) {
+ Log.i(TAG, "Initial sync setup failed: ${e.message}")
+ }
+ }
+
+ private fun restorePeriodicSyncSchedule() {
+ try {
+ val settings = credentialManager.get().settings.value
+ SyncScheduler.reconcilePeriodicSync(applicationContext, settings)
+ } catch (e: Exception) {
+ Log.i(TAG, "Periodic sync reconcile failed: ${e.message}")
+ }
+ }
+
override fun onRestart() {
super.onRestart()
// redraw after device sleep
diff --git a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt
index d16eecb9..b1ec94e1 100644
--- a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt
+++ b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt
@@ -185,7 +185,10 @@ class PageDataManager @Inject constructor(
*/
suspend fun requestCurrentPageLoadJoin(
) {
- assert(currentPage == pageFromDb?.id)
+ if (currentPage != pageFromDb?.id) {
+ log.e("Skipping load for invalid current page: current=$currentPage db=${pageFromDb?.id}")
+ return
+ }
val bookId = pageFromDb?.notebookId
log.d("requestCurrentPageLoadJoin($currentPage)")
getOrStartLoadingJob(currentPage, bookId).join()
@@ -208,7 +211,10 @@ class PageDataManager @Inject constructor(
}
suspend fun cacheNeighbors() {
- assert(currentPage == pageFromDb?.id)
+ if (currentPage != pageFromDb?.id) {
+ log.e("Skipping neighbors cache for invalid current page: current=$currentPage db=${pageFromDb?.id}")
+ return
+ }
val bookId = pageFromDb?.notebookId ?: return
log.d("cacheNeighbors($currentPage)")
@@ -573,6 +579,7 @@ class PageDataManager @Inject constructor(
fun updateStrokesInDb(strokes: List) {
dataScope.launch {
appRepository.strokeRepository.update(strokes)
+ updateParentNotebookTimestamp()
}
}
@@ -591,27 +598,37 @@ class PageDataManager @Inject constructor(
)
appRepository.strokeRepository.update(strokes)
}
+ updateParentNotebookTimestamp()
}
}
fun saveImagesToDb(images: List) {
dataScope.launch {
appRepository.imageRepository.create(images)
+ updateParentNotebookTimestamp()
}
}
fun removeStrokesFromDb(strokes: List) {
dataScope.launch {
appRepository.strokeRepository.deleteAll(strokes)
+ updateParentNotebookTimestamp()
}
}
fun removeImagesFromDb(images: List) {
dataScope.launch {
appRepository.imageRepository.deleteAll(images)
+ updateParentNotebookTimestamp()
}
}
+ private suspend fun updateParentNotebookTimestamp() {
+ val notebookId = pageFromDb?.notebookId ?: return
+ val notebook = appRepository.bookRepository.getById(notebookId) ?: return
+ appRepository.bookRepository.update(notebook)
+ }
+
fun setScrollInDb() {
dataScope.launch {
appRepository.pageRepository.updateScroll(
diff --git a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt
index 29851b96..d1d2d065 100644
--- a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt
+++ b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt
@@ -20,7 +20,6 @@ object GlobalAppSettings {
}
}
-
@Serializable
data class AppSettings(
// General
@@ -47,7 +46,7 @@ data class AppSettings(
val twoFingerSwipeRightAction: GestureAction? = defaultTwoFingerSwipeRightAction,
val holdAction: GestureAction? = defaultHoldAction,
val enableQuickNav: Boolean = true,
-
+ val renameOnCreate: Boolean = true,
// Debug
val showWelcome: Boolean = true,
@@ -76,4 +75,4 @@ data class AppSettings(
enum class Position {
Top, Bottom, // Left,Right,
}
-}
\ No newline at end of file
+}
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/di/CoroutinesModule.kt b/app/src/main/java/com/ethran/notable/di/CoroutinesModule.kt
index f74d55d7..fb1bb575 100644
--- a/app/src/main/java/com/ethran/notable/di/CoroutinesModule.kt
+++ b/app/src/main/java/com/ethran/notable/di/CoroutinesModule.kt
@@ -56,4 +56,4 @@ object CoroutinesModule {
fun provideApplicationScope(
@IoDispatcher ioDispatcher: CoroutineDispatcher
): CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher)
-}
\ No newline at end of file
+}
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 1ba84d98..1812fe50 100644
--- a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
+++ b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
@@ -14,6 +14,7 @@ import com.ethran.notable.editor.state.SelectionState
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.sync.SyncLogger
import io.shipbook.shipbooksdk.ShipBook
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -94,6 +95,12 @@ 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 (sync logic inside syncOrchestrator.syncFromPageId)
+ val oldPageId = page.currentPageId
+ scope.launch(Dispatchers.IO) {
+ triggerSyncForPage(oldPageId)
+ }
+
// Switch to Main thread for Compose state mutations
withContext(Dispatchers.Main) {
viewModel.changePage(id)
@@ -106,6 +113,18 @@ class EditorControlTower(
// }
}
+ /**
+ * Trigger sync for a specific page's notebook.
+ */
+ private suspend fun triggerSyncForPage(pageId: String?) {
+ if (pageId == null) return
+ try {
+ viewModel.syncFromPageId(pageId)
+ } catch (e: Exception) {
+ SyncLogger.e("EditorControlTower", "Sync failed: ${e.message}")
+ }
+ }
+
fun setIsDrawing(value: Boolean) {
if (viewModel.toolbarState.value.isDrawing == value) {
logEditorControlTower.w("IsDrawing already set to $value")
@@ -300,4 +319,4 @@ class EditorControlTower(
}
fun showHint(text: String) = viewModel.showHint(text)
-}
\ No newline at end of file
+}
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 813220e0..de2828ff 100644
--- a/app/src/main/java/com/ethran/notable/editor/EditorView.kt
+++ b/app/src/main/java/com/ethran/notable/editor/EditorView.kt
@@ -121,6 +121,8 @@ fun EditorView(
viewModel.updateDrawingState()
}
+// val editorControlTower = remember {
+// EditorControlTower(scope, page, history, editorState, context, appRepository).apply { registerObservers() }
DisposableEffect(editorControlTower) {
editorControlTower.registerObservers()
onDispose {
@@ -196,9 +198,7 @@ fun EditorView(
// Observe pageId changes from ViewModel state for navigation
LaunchedEffect(viewModel) {
- snapshotFlow { toolbarState.pageId }
- .filterNotNull()
- .distinctUntilChanged()
+ snapshotFlow { toolbarState.pageId }.filterNotNull().distinctUntilChanged()
.drop(1) // Skip initial emission from loadBookData
.collect { newPageId ->
log.v("EditorView: snapshotFlow detected pageId change to $newPageId, triggering onPageChange")
@@ -214,8 +214,7 @@ fun EditorView(
val zoomLevel by page.zoomLevel.collectAsStateWithLifecycle()
val selectionActive = viewModel.selectionState.isNonEmpty()
LaunchedEffect(
- zoomLevel,
- selectionActive
+ zoomLevel, selectionActive
) {
log.v("EditorView: zoomLevel=$zoomLevel, selectionActive=$selectionActive")
viewModel.setShowResetView(zoomLevel != 1.0f)
diff --git a/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt b/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt
index 322fa429..39574bca 100644
--- a/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt
+++ b/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt
@@ -14,6 +14,7 @@ import com.ethran.notable.data.datastore.GlobalAppSettings
import com.ethran.notable.data.db.getPageIndex
import com.ethran.notable.data.db.getParentFolder
import com.ethran.notable.data.model.BackgroundType
+import com.ethran.notable.di.ApplicationScope
import com.ethran.notable.editor.EditorViewModel.Companion.DEFAULT_PEN_SETTINGS
import com.ethran.notable.editor.canvas.CanvasEventBus
import com.ethran.notable.editor.state.ClipboardStore
@@ -26,12 +27,14 @@ import com.ethran.notable.editor.utils.PenSetting
import com.ethran.notable.io.ExportEngine
import com.ethran.notable.io.ExportFormat
import com.ethran.notable.io.ExportTarget
+import com.ethran.notable.sync.SyncOrchestrator
import com.ethran.notable.ui.SnackConf
import com.ethran.notable.ui.SnackDispatcher
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.shipbook.shipbooksdk.Log
import io.shipbook.shipbooksdk.ShipBook
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -159,12 +162,14 @@ sealed class EditorUiEvent {
@HiltViewModel
class EditorViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
- val appRepository: AppRepository,
- var editorSettingCacheManager: EditorSettingCacheManager,
+ private val appRepository: AppRepository,
+ private val editorSettingCacheManager: EditorSettingCacheManager,
private val exportEngine: ExportEngine,
val pageDataManager: PageDataManager,
+ private val syncOrchestrator: SyncOrchestrator,
val snackDispatcher: SnackDispatcher,
- private val historyFactory: History.Factory
+ private val historyFactory: History.Factory,
+ @param:ApplicationScope private val appScope: CoroutineScope
) : ViewModel() {
// ---- Toolbar / UI State (single flat flow) ----
private val _toolbarState = MutableStateFlow(ToolbarUiState())
@@ -215,12 +220,18 @@ class EditorViewModel @Inject constructor(
}
}
+ /**
+ * Called when the EditorView is being disposed.
+ * Performs cleanup, exports linked files, and triggers auto-sync.
+ */
fun onDispose(page: PageView) {
- // finish selection operation
+ // 1. Finish selection operation
selectionState.applySelectionDisplace(page)
bookId?.let { bookId ->
exportEngine.exportToLinkedFileAsync(bookId)
}
+
+ // 3. Cleanup page resources
page.disposeOldPage()
}
@@ -368,8 +379,7 @@ class EditorViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
try {
val result = exportEngine.export(target, format)
- val snack = SnackConf(text = result, duration = 4000)
- snackDispatcher.showOrUpdateSnack(snack)
+ snackDispatcher.showOrUpdateSnack(SnackConf(text = result, duration = 4000))
} catch (e: Exception) {
snackDispatcher.showOrUpdateSnack(
SnackConf(
@@ -600,8 +610,9 @@ class EditorViewModel @Inject constructor(
_toolbarState.update { it.copy(pageId = newPageId) }
} else {
Log.d("EditorView", "Tried to change to same page!")
- val snack = SnackConf(text = "Tried to change to same page!", duration = 4000)
- snackDispatcher.showOrUpdateSnack(snack)
+ snackDispatcher.showOrUpdateSnack(
+ SnackConf(text = "Tried to change to same page!", duration = 4000)
+ )
}
}
@@ -683,10 +694,13 @@ class EditorViewModel @Inject constructor(
}
- // Hints for Editor
fun showHint(message: String, durationMs: Int = 1500) {
snackDispatcher.showOrUpdateSnack(
SnackConf(text = message, duration = durationMs)
)
}
-}
\ No newline at end of file
+
+ suspend fun syncFromPageId(pageId: String) {
+ syncOrchestrator.syncFromPageId(pageId)
+ }
+}
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 59ea86bf..dd7ba711 100644
--- a/app/src/main/java/com/ethran/notable/editor/PageView.kt
+++ b/app/src/main/java/com/ethran/notable/editor/PageView.kt
@@ -387,6 +387,7 @@ class PageView(
private fun saveImagesToPersistLayer(image: List) = pageDataManager.saveImagesToDb(image)
+
fun addImage(imageToAdd: Image) {
images += listOf(imageToAdd)
val bottomPlusPadding = imageToAdd.x + imageToAdd.height + 50
@@ -429,7 +430,6 @@ class PageView(
private fun removeImagesFromPersistLayer(imageIds: List) =
pageDataManager.removeImagesFromDb(imageIds)
-
// load background, fast, if it is accurate enough.
private fun loadInitialBitmap(): Boolean {
val bitmapFromDisc = loadHQPagePreview(
diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt
index c7ad73d4..471e6061 100644
--- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt
+++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt
@@ -116,8 +116,11 @@ class DrawCanvas(
log.i("surface created $holder")
// set up the drawing surface
inputHandler.updateActiveSurface()
- // Restore the correct stroke size and style, now its done in initFromPersistedSettings
-// inputHandler.updatePenAndStroke()
+ // The surface is only drawable once this callback fires; any
+ // refreshUi attempts before this silently no-op (lockCanvas
+ // returns null). Paint now so the newly-created surface
+ // isn't left blank.
+ this@DrawCanvas.post { refreshManager.refreshUi(null) }
}
override fun surfaceChanged(
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..8be52a44
--- /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(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..991a83d9
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/CredentialManager.kt
@@ -0,0 +1,147 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.Serializable
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Serializable
+data class SyncSettings(
+ val syncEnabled: Boolean = false,
+ val serverUrl: String = "",
+ val username: String = "",
+ val autoSync: Boolean = true,
+ val syncInterval: Int = 15, // minutes
+ val lastSyncTime: String? = null,
+ val syncOnNoteClose: Boolean = true,
+ val wifiOnly: Boolean = false,
+ val syncedNotebookIds: Set = emptySet()
+)
+
+/**
+ * Manages secure storage of WebDAV credentials and sync settings.
+ * Everything is stored in EncryptedSharedPreferences to ensure security at rest
+ * and to keep sync state independent of general app settings.
+ */
+@Singleton
+class CredentialManager @Inject constructor(
+ @param:ApplicationContext 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
+ )
+
+ private val _settings = MutableStateFlow(loadSettings())
+ val settings: StateFlow = _settings.asStateFlow()
+
+ private fun loadSettings(): SyncSettings {
+ return SyncSettings(
+ syncEnabled = encryptedPrefs.getBoolean(KEY_SYNC_ENABLED, false),
+ serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, "") ?: "",
+ username = encryptedPrefs.getString(KEY_USERNAME, "") ?: "",
+ autoSync = encryptedPrefs.getBoolean(KEY_AUTO_SYNC, true),
+ syncInterval = encryptedPrefs.getInt(KEY_SYNC_INTERVAL, 15),
+ lastSyncTime = encryptedPrefs.getString(KEY_LAST_SYNC_TIME, null),
+ syncOnNoteClose = encryptedPrefs.getBoolean(KEY_SYNC_ON_CLOSE, true),
+ wifiOnly = encryptedPrefs.getBoolean(KEY_WIFI_ONLY, false),
+ syncedNotebookIds = encryptedPrefs.getStringSet(KEY_SYNCED_IDS, emptySet())
+ ?: emptySet()
+ )
+ }
+
+ fun updateSettings(transform: (SyncSettings) -> SyncSettings) {
+ val newSettings = transform(_settings.value)
+ encryptedPrefs.edit {
+ putBoolean(KEY_SYNC_ENABLED, newSettings.syncEnabled)
+ putString(KEY_SERVER_URL, newSettings.serverUrl)
+ putString(KEY_USERNAME, newSettings.username)
+ putBoolean(KEY_AUTO_SYNC, newSettings.autoSync)
+ putInt(KEY_SYNC_INTERVAL, newSettings.syncInterval)
+ putString(KEY_LAST_SYNC_TIME, newSettings.lastSyncTime)
+ putBoolean(KEY_SYNC_ON_CLOSE, newSettings.syncOnNoteClose)
+ putBoolean(KEY_WIFI_ONLY, newSettings.wifiOnly)
+ putStringSet(KEY_SYNCED_IDS, newSettings.syncedNotebookIds)
+ }
+ _settings.value = newSettings
+ }
+
+ /**
+ * 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)
+ }
+ // Update the flow as well
+ updateSettings { it.copy(username = username) }
+ }
+
+ /**
+ * Retrieve WebDAV password.
+ */
+ fun getPassword(): String? {
+ return encryptedPrefs.getString(KEY_PASSWORD, null)
+ }
+
+ /**
+ * Retrieve full credentials.
+ */
+ 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 {
+ remove(KEY_USERNAME)
+ remove(KEY_PASSWORD)
+ }
+ updateSettings { it.copy(username = "") }
+ }
+
+ /**
+ * 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"
+ private const val KEY_SYNC_ENABLED = "sync_enabled"
+ private const val KEY_SERVER_URL = "server_url"
+ private const val KEY_AUTO_SYNC = "auto_sync"
+ private const val KEY_SYNC_INTERVAL = "sync_interval"
+ private const val KEY_LAST_SYNC_TIME = "last_sync_time"
+ private const val KEY_SYNC_ON_CLOSE = "sync_on_close"
+ private const val KEY_WIFI_ONLY = "wifi_only"
+ private const val KEY_SYNCED_IDS = "synced_ids"
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt b/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt
new file mode 100644
index 00000000..19e9074f
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt
@@ -0,0 +1,69 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.data.db.Folder
+import com.ethran.notable.sync.serializers.FolderSerializer
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FolderSyncService @Inject constructor(
+ private val appRepository: AppRepository
+) {
+ private val folderSerializer = FolderSerializer
+ suspend fun syncFolders(webdavClient: WebDAVClient) {
+ SyncLogger.i("FolderSyncService", "Syncing folders...")
+ try {
+ val localFolders = appRepository.folderRepository.getAll()
+ val remotePath = SyncPaths.foldersFile()
+ if (webdavClient.exists(remotePath)) {
+ val remoteFile = webdavClient.getFileWithMetadata(remotePath)
+ val remoteEtag = remoteFile.etag
+ ?: throw IOException("Missing ETag for $remotePath")
+ val remoteFoldersJson = remoteFile.content.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",
+ ifMatch = remoteEtag
+ )
+ SyncLogger.i("FolderSyncService", "Synced ${mergedFolders.size} folders")
+ } else {
+ if (localFolders.isNotEmpty()) {
+ val foldersJson = folderSerializer.serializeFolders(localFolders)
+ webdavClient.putFile(remotePath, foldersJson.toByteArray(), "application/json")
+ SyncLogger.i(
+ "FolderSyncService",
+ "Uploaded ${localFolders.size} folders to server"
+ )
+ }
+ }
+ } catch (e: Exception) {
+ SyncLogger.e(
+ "FolderSyncService",
+ "Error syncing folders: ${e.message}\n${e.stackTraceToString()}"
+ )
+ throw e
+ }
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt b/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt
new file mode 100644
index 00000000..62666cd1
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt
@@ -0,0 +1,112 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.sync.serializers.NotebookSerializer
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NotebookReconciliationService @Inject constructor(
+ private val appRepository: AppRepository,
+ private val credentialManager: CredentialManager,
+ private val syncPreflightService: SyncPreflightService,
+ private val notebookSyncService: NotebookSyncService,
+ private val reporter: SyncProgressReporter
+) {
+ private val notebookSerializer = NotebookSerializer()
+ private val logger = SyncLogger
+
+ suspend fun syncExistingNotebooks(webdavClient: WebDAVClient): Set {
+ val localNotebooks = appRepository.bookRepository.getAll()
+ val preDownloadNotebookIds = localNotebooks.map { it.id }.toSet()
+ val total = localNotebooks.size
+
+ localNotebooks.forEachIndexed { i, notebook ->
+ reporter.beginItem(index = i + 1, total = total, name = notebook.title)
+ try {
+ syncNotebook(notebook.id, webdavClient)
+ } catch (e: Exception) {
+ logger.e(TAG, "Failed to sync ${notebook.title}: ${e.message}")
+ }
+ }
+ reporter.endItem()
+
+ return preDownloadNotebookIds
+ }
+
+ suspend fun syncNotebook(notebookId: String, webdavClient: WebDAVClient): SyncResult {
+ return try {
+ logger.i(TAG, "Syncing notebook: $notebookId")
+ val settings = credentialManager.settings.value
+
+ if (!settings.syncEnabled) return SyncResult.Success
+ if (!syncPreflightService.checkWifiConstraint()) return SyncResult.Success
+
+ val skewMs = syncPreflightService.checkClockSkew(webdavClient)
+ if (skewMs != null && kotlin.math.abs(skewMs) > CLOCK_SKEW_THRESHOLD_MS) {
+ 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)
+
+ if (remoteExists) {
+ val remoteManifest = webdavClient.getFileWithMetadata(remotePath)
+ val remoteEtag = remoteManifest.etag
+ ?: throw IOException("Missing ETag for $remotePath")
+ val remoteManifestJson = remoteManifest.content.decodeToString()
+ val remoteUpdatedAt = notebookSerializer.getManifestUpdatedAt(remoteManifestJson)
+ val diffMs = remoteUpdatedAt?.let { localNotebook.updatedAt.time - it.time }
+ ?: Long.MAX_VALUE
+
+ when {
+ remoteUpdatedAt == null -> notebookSyncService.uploadNotebook(
+ localNotebook,
+ webdavClient,
+ manifestIfMatch = remoteEtag
+ )
+
+ diffMs < -TIMESTAMP_TOLERANCE_MS -> notebookSyncService.downloadNotebook(
+ notebookId,
+ webdavClient
+ )
+
+ diffMs > TIMESTAMP_TOLERANCE_MS -> notebookSyncService.uploadNotebook(
+ localNotebook,
+ webdavClient,
+ manifestIfMatch = remoteEtag
+ )
+
+ else -> logger.i(
+ TAG,
+ "= No changes (within tolerance), skipping ${localNotebook.title}"
+ )
+ }
+ } else {
+ notebookSyncService.uploadNotebook(localNotebook, webdavClient)
+ }
+
+ SyncResult.Success
+ } catch (e: PreconditionFailedException) {
+ logger.w(TAG, "Conflict syncing notebook $notebookId: ${e.message}")
+ SyncResult.Failure(SyncError.CONFLICT)
+ } catch (e: IOException) {
+ logger.e(TAG, "Network error syncing notebook $notebookId: ${e.message}")
+ SyncResult.Failure(SyncError.NETWORK_ERROR)
+ } catch (e: Exception) {
+ logger.e(TAG, "Error syncing notebook $notebookId: ${e.message}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ companion object {
+ private const val TAG = "NotebookReconciliationService"
+ private const val TIMESTAMP_TOLERANCE_MS = 1000L
+ private const val CLOCK_SKEW_THRESHOLD_MS = 30_000L
+ }
+}
+
diff --git a/app/src/main/java/com/ethran/notable/sync/NotebookSyncService.kt b/app/src/main/java/com/ethran/notable/sync/NotebookSyncService.kt
new file mode 100644
index 00000000..0c2a04d2
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/NotebookSyncService.kt
@@ -0,0 +1,276 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.data.db.Notebook
+import com.ethran.notable.data.db.Page
+import com.ethran.notable.data.ensureBackgroundsFolder
+import com.ethran.notable.data.ensureImagesFolder
+import com.ethran.notable.sync.serializers.NotebookSerializer
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NotebookSyncService @Inject constructor(
+ private val appRepository: AppRepository,
+ private val reporter: SyncProgressReporter
+) {
+ private val notebookSerializer = NotebookSerializer()
+ private val TAG = "NotebookSyncService"
+ private val sLog = SyncLogger
+ suspend fun applyRemoteDeletions(webdavClient: WebDAVClient, maxAgeDays: Long): 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
+ 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}")
+ }
+ }
+ }
+ val cutoff = java.util.Date(System.currentTimeMillis() - maxAgeDays * 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 $maxAgeDays 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
+ }
+
+ fun detectAndUploadLocalDeletions(
+ webdavClient: WebDAVClient, settings: SyncSettings, preDownloadNotebookIds: Set
+ ): Int {
+ sLog.i(TAG, "Detecting local deletions...")
+ val syncedNotebookIds = settings.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)
+ }
+ 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
+ }
+
+ suspend fun downloadNewNotebooks(
+ webdavClient: WebDAVClient,
+ tombstonedIds: Set,
+ settings: SyncSettings,
+ 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.syncedNotebookIds }
+ if (newNotebookIds.isNotEmpty()) {
+ sLog.i(TAG, "Found ${newNotebookIds.size} new notebook(s) on server")
+ val total = newNotebookIds.size
+ newNotebookIds.forEachIndexed { i, notebookId ->
+ reporter.beginItem(index = i + 1, total = total, name = notebookId)
+ 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}")
+ }
+ }
+ reporter.endItem()
+ } else {
+ sLog.i(TAG, "No new notebooks on server")
+ }
+ return newNotebookIds.size
+ }
+
+ suspend fun uploadNotebook(
+ notebook: Notebook,
+ webdavClient: WebDAVClient,
+ manifestIfMatch: String? = null
+ ) {
+ 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",
+ ifMatch = manifestIfMatch
+ )
+ val pages = appRepository.pageRepository.getByIds(notebook.pageIds)
+ for (page in pages) {
+ uploadPage(page, notebookId, webdavClient)
+ }
+ 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}")
+ }
+
+ private suspend fun uploadPage(page: Page, notebookId: String, webdavClient: WebDAVClient) {
+ val pageWithData = appRepository.pageRepository.getWithDataById(page.id)
+ val pageJson = notebookSerializer.serializePage(
+ page, pageWithData.strokes, pageWithData.images
+ )
+ webdavClient.putFile(
+ SyncPaths.pageFile(notebookId, page.id), pageJson.toByteArray(), "application/json"
+ )
+ for (image in pageWithData.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}")
+ }
+ }
+ }
+ }
+
+ 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}")
+ }
+
+ 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 pageWithData = appRepository.pageRepository.getWithDataById(page.id)
+ appRepository.strokeRepository.deleteAll(pageWithData.strokes.map { it.id })
+ appRepository.imageRepository.deleteAll(pageWithData.images.map { it.id })
+ appRepository.pageRepository.update(page)
+ } else {
+ appRepository.pageRepository.create(page)
+ }
+ appRepository.strokeRepository.create(strokes)
+ appRepository.imageRepository.create(updatedImages)
+ }
+
+ private fun extractFilename(uri: String): String {
+ return uri.substringAfterLast('/')
+ }
+
+ 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"
+ }
+ }
+}
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..be58a951
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncEngine.kt
@@ -0,0 +1,1022 @@
+package com.ethran.notable.sync
+//
+//import android.content.Context
+//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.ensureBackgroundsFolder
+//import com.ethran.notable.data.ensureImagesFolder
+//import dagger.hilt.EntryPoint
+//import dagger.hilt.InstallIn
+//import dagger.hilt.android.qualifiers.ApplicationContext
+//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
+//import javax.inject.Inject
+//import javax.inject.Singleton
+//
+//// Alias for cleaner code
+//private val SLog = SyncLogger
+//
+///**
+// * Core sync engine orchestrating WebDAV synchronization.
+// * Handles bidirectional sync of folders, notebooks, pages, and files.
+// */
+//@Singleton
+//class SyncEngine @Inject constructor(
+// @param:ApplicationContext private val context: Context,
+// private val appRepository: AppRepository,
+// private val kvProxy: KvProxy,
+// private val credentialManager: CredentialManager
+//) {
+//
+// 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 settings = credentialManager.settings.value
+// val credentials = credentialManager.getCredentials()
+//
+// if (!settings.syncEnabled) {
+// SLog.i(TAG, "Sync disabled in settings")
+// return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+// }
+//
+// if (credentials == null) {
+// SLog.w(TAG, "No credentials found")
+// return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+// }
+//
+// val webdavClient = WebDAVClient(
+// settings.serverUrl, credentials.first, credentials.second
+// )
+//
+// if (settings.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()
+//
+// 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.
+// */
+// suspend fun syncNotebook(notebookId: String): SyncResult = withContext(Dispatchers.IO) {
+// if (syncMutex.isLocked) {
+// SLog.i(TAG, "Full sync in progress, skipping per-notebook sync for $notebookId")
+// return@withContext SyncResult.Success
+// }
+// return@withContext syncNotebookImpl(notebookId)
+// }
+//
+// /**
+// * Trigger auto-sync for a page when it is closed/switched, if enabled in settings.
+// */
+// suspend fun syncFromPageId(pageId: String) {
+// val settings = credentialManager.settings.value
+// if (!settings.syncEnabled || !settings.syncOnNoteClose) return
+//
+// try {
+// val pageEntity = appRepository.pageRepository.getById(pageId) ?: return
+// pageEntity.notebookId?.let { notebookId ->
+// SLog.i("EditorSync", "Auto-syncing notebook $notebookId on page close")
+// syncNotebook(notebookId)
+// }
+// } catch (e: Exception) {
+// SLog.e("EditorSync", "Auto-sync failed: ${e.message}")
+// }
+// }
+//
+// private suspend fun syncNotebookImpl(notebookId: String): SyncResult {
+// return try {
+// SLog.i(TAG, "Syncing notebook: $notebookId")
+//
+// val settings = credentialManager.settings.value
+// if (!settings.syncEnabled) return SyncResult.Success
+//
+// if (settings.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.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)
+// }
+// }
+//
+// suspend fun uploadDeletion(notebookId: String): SyncResult = withContext(Dispatchers.IO) {
+// return@withContext try {
+// SLog.i(TAG, "Uploading deletion for notebook: $notebookId")
+//
+// val settings = credentialManager.settings.value
+// if (!settings.syncEnabled) return@withContext SyncResult.Success
+//
+// if (settings.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.serverUrl, credentials.first, credentials.second
+// )
+//
+// val notebookPath = SyncPaths.notebookDir(notebookId)
+// if (webdavClient.exists(notebookPath)) {
+// SLog.i(TAG, "✗ Deleting notebook content from server: $notebookId")
+// webdavClient.delete(notebookPath)
+// }
+//
+// webdavClient.putFile(
+// SyncPaths.tombstone(notebookId), ByteArray(0), "application/octet-stream"
+// )
+// SLog.i(TAG, "✓ Tombstone uploaded for: $notebookId")
+//
+// val updatedSyncedIds = settings.syncedNotebookIds - notebookId
+// credentialManager.updateSettings { it.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)
+// }
+// }
+//
+// 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
+// }
+// }
+//
+// 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
+//
+// 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}")
+// }
+// }
+// }
+//
+// 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
+// }
+//
+// private fun detectAndUploadLocalDeletions(
+// webdavClient: WebDAVClient, settings: SyncSettings, preDownloadNotebookIds: Set
+// ): Int {
+// SLog.i(TAG, "Detecting local deletions...")
+//
+// val syncedNotebookIds = settings.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)
+// }
+//
+// 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
+// }
+//
+// 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)
+// }
+//
+// 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}")
+// }
+//
+// 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}")
+// }
+// }
+// }
+// }
+//
+// 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}")
+// }
+//
+// 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)
+// }
+//
+// suspend fun forceUploadAll(): SyncResult = withContext(Dispatchers.IO) {
+// return@withContext try {
+// SLog.i(TAG, "⚠ FORCE UPLOAD: Replacing server with local data")
+//
+// val settings = credentialManager.settings.value
+// val credentials = credentialManager.getCredentials() ?: return@withContext SyncResult.Failure(
+// SyncError.AUTH_ERROR
+// )
+//
+// val webdavClient = WebDAVClient(
+// settings.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)
+// }
+// }
+//
+// suspend fun forceDownloadAll(): SyncResult = withContext(Dispatchers.IO) {
+// return@withContext try {
+// SLog.i(TAG, "⚠ FORCE DOWNLOAD: Replacing local with server data")
+//
+// val settings = credentialManager.settings.value
+// val credentials = credentialManager.getCredentials() ?: return@withContext SyncResult.Failure(
+// SyncError.AUTH_ERROR
+// )
+//
+// val webdavClient = WebDAVClient(
+// settings.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)
+// }
+// }
+//
+// private fun extractFilename(uri: String): String {
+// return uri.substringAfterLast('/')
+// }
+//
+// 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"
+// }
+// }
+//
+// private fun checkClockSkew(webdavClient: WebDAVClient): Long? {
+// val serverTime = webdavClient.getServerTime() ?: return null
+// return System.currentTimeMillis() - serverTime
+// }
+//
+// 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)
+// }
+//
+// 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}")
+// }
+// }
+//
+// 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
+// }
+//
+// private suspend fun downloadNewNotebooks(
+// webdavClient: WebDAVClient,
+// tombstonedIds: Set,
+// settings: SyncSettings,
+// 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.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
+// }
+//
+// private suspend fun updateSyncedNotebookIds() {
+// val currentNotebookIds = appRepository.bookRepository.getAll().map { it.id }.toSet()
+// credentialManager.updateSettings {
+// it.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
+// }
+// }
+//
+//}
+//
+///**
+// * Hilt entry point so non-Hilt-managed contexts (Workers, background code)
+// * can obtain the injected SyncEngine instance and KvProxy.
+// */
+//@EntryPoint
+//@InstallIn(SingletonComponent::class)
+//interface SyncEngineEntryPoint {
+// fun syncEngine(): SyncEngine
+// fun kvProxy(): KvProxy
+// fun credentialManager(): CredentialManager
+//}
+//
+///**
+// * Result of a sync operation.
+// */
+//sealed class SyncResult {
+// data 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 {
+// data 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/SyncForceService.kt b/app/src/main/java/com/ethran/notable/sync/SyncForceService.kt
new file mode 100644
index 00000000..1ce307d6
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncForceService.kt
@@ -0,0 +1,143 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.sync.serializers.FolderSerializer
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SyncForceService @Inject constructor(
+ private val appRepository: AppRepository,
+ private val credentialManager: CredentialManager,
+ private val syncPreflightService: SyncPreflightService,
+ private val notebookSyncService: NotebookSyncService,
+ private val webDavClientFactory: WebDavClientFactoryPort
+) {
+ private val folderSerializer = FolderSerializer
+ private val logger = SyncLogger
+
+ suspend fun forceUploadAll(): SyncResult {
+ return try {
+ logger.i(TAG, "FORCE UPLOAD: Replacing server with local data")
+ val settings = credentialManager.settings.value
+ val credentials = credentialManager.getCredentials()
+ ?: return SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient =
+ webDavClientFactory.create(
+ settings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ try {
+ if (webdavClient.exists(SyncPaths.notebooksDir())) {
+ val existingNotebooks = webdavClient.listCollection(SyncPaths.notebooksDir())
+ logger.i(
+ TAG,
+ "Deleting ${existingNotebooks.size} existing notebooks from server"
+ )
+ existingNotebooks.forEach { notebookDir ->
+ try {
+ webdavClient.delete(SyncPaths.notebookDir(notebookDir))
+ } catch (e: Exception) {
+ logger.w(TAG, "Failed to delete $notebookDir: ${e.message}")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ logger.w(TAG, "Error cleaning server notebooks: ${e.message}")
+ }
+
+ syncPreflightService.ensureServerDirectories(webdavClient)
+
+ val folders = appRepository.folderRepository.getAll()
+ if (folders.isNotEmpty()) {
+ val foldersJson = folderSerializer.serializeFolders(folders)
+ webdavClient.putFile(
+ SyncPaths.foldersFile(),
+ foldersJson.toByteArray(),
+ "application/json"
+ )
+ logger.i(TAG, "Uploaded ${folders.size} folders")
+ }
+
+ val notebooks = appRepository.bookRepository.getAll()
+ logger.i(TAG, "Uploading ${notebooks.size} local notebooks...")
+ notebooks.forEach { notebook ->
+ try {
+ notebookSyncService.uploadNotebook(notebook, webdavClient)
+ logger.i(TAG, "Uploaded: ${notebook.title}")
+ } catch (e: Exception) {
+ logger.e(TAG, "Failed to upload ${notebook.title}: ${e.message}")
+ }
+ }
+
+ logger.i(TAG, "FORCE UPLOAD complete: ${notebooks.size} notebooks")
+ SyncResult.Success
+ } catch (e: Exception) {
+ logger.e(TAG, "Force upload failed: ${e.message}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ suspend fun forceDownloadAll(): SyncResult {
+ return try {
+ logger.i(TAG, "FORCE DOWNLOAD: Replacing local with server data")
+ val settings = credentialManager.settings.value
+ val credentials = credentialManager.getCredentials()
+ ?: return SyncResult.Failure(SyncError.AUTH_ERROR)
+
+ val webdavClient =
+ webDavClientFactory.create(
+ settings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+
+ val localFolders = appRepository.folderRepository.getAll()
+ localFolders.forEach { appRepository.folderRepository.delete(it.id) }
+
+ val localNotebooks = appRepository.bookRepository.getAll()
+ localNotebooks.forEach { appRepository.bookRepository.delete(it.id) }
+
+ logger.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)
+ folders.forEach { appRepository.folderRepository.create(it) }
+ logger.i(TAG, "Downloaded ${folders.size} folders from server")
+ }
+
+ if (webdavClient.exists(SyncPaths.notebooksDir())) {
+ val notebookDirs = webdavClient.listCollection(SyncPaths.notebooksDir())
+ logger.i(TAG, "Found ${notebookDirs.size} notebook(s) on server")
+ notebookDirs.forEach { notebookDir ->
+ try {
+ val notebookId = notebookDir.trimEnd('/')
+ notebookSyncService.downloadNotebook(notebookId, webdavClient)
+ } catch (e: Exception) {
+ logger.e(TAG, "Failed to download $notebookDir: ${e.message}")
+ }
+ }
+ } else {
+ logger.w(TAG, "${SyncPaths.notebooksDir()} doesn't exist on server")
+ }
+
+ logger.i(TAG, "FORCE DOWNLOAD complete")
+ SyncResult.Success
+ } catch (e: Exception) {
+ logger.e(TAG, "Force download failed: ${e.message}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ companion object {
+ private const val TAG = "SyncForceService"
+ }
+}
+
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/SyncOrchestrator.kt b/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt
new file mode 100644
index 00000000..bcc12787
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt
@@ -0,0 +1,270 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.data.AppRepository
+import com.ethran.notable.data.db.KvProxy
+import com.ethran.notable.di.IoDispatcher
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SyncOrchestrator @Inject constructor(
+ private val appRepository: AppRepository,
+ private val credentialManager: CredentialManager,
+ private val syncPreflightService: SyncPreflightService,
+ private val folderSyncService: FolderSyncService,
+ private val notebookSyncService: NotebookSyncService,
+ private val syncForceService: SyncForceService,
+ private val notebookReconciliationService: NotebookReconciliationService,
+ private val webDavClientFactory: WebDavClientFactoryPort,
+ private val reporter: SyncProgressReporter,
+ @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher
+) {
+ private val sLog = SyncLogger
+ suspend fun syncAllNotebooks(): SyncResult = withContext(ioDispatcher) {
+ if (!syncMutex.tryLock()) {
+ sLog.w(TAG, "Sync already in progress, skipping")
+ return@withContext SyncResult.Failure(SyncError.SYNC_IN_PROGRESS)
+ }
+ val startTime = System.currentTimeMillis()
+ return@withContext try {
+ sLog.i(TAG, "Starting full sync...")
+ reporter.beginStep(SyncStep.INITIALIZING, PROGRESS_INITIALIZING, "Initializing sync...")
+ val settings = credentialManager.settings.value
+ val credentials = credentialManager.getCredentials()
+ if (!settings.syncEnabled) {
+ reporter.finishError(SyncError.CONFIG_ERROR, canRetry = false)
+ return@withContext SyncResult.Failure(SyncError.CONFIG_ERROR)
+ }
+ if (credentials == null) {
+ reporter.finishError(SyncError.AUTH_ERROR, canRetry = false)
+ return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+ }
+ if (!syncPreflightService.checkWifiConstraint()) {
+ reporter.finishError(SyncError.WIFI_REQUIRED, canRetry = false)
+ return@withContext SyncResult.Failure(SyncError.WIFI_REQUIRED)
+ }
+ val webdavClient =
+ webDavClientFactory.create(
+ settings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+ val skewMs = syncPreflightService.checkClockSkew(webdavClient)
+ if (skewMs != null && kotlin.math.abs(skewMs) > CLOCK_SKEW_THRESHOLD_MS) {
+ reporter.finishError(SyncError.CLOCK_SKEW, canRetry = false)
+ return@withContext SyncResult.Failure(SyncError.CLOCK_SKEW)
+ }
+ syncPreflightService.ensureServerDirectories(webdavClient)
+ reporter.beginStep(SyncStep.SYNCING_FOLDERS, PROGRESS_SYNCING_FOLDERS, "Syncing folders...")
+ folderSyncService.syncFolders(webdavClient)
+ reporter.beginStep(SyncStep.APPLYING_DELETIONS, PROGRESS_APPLYING_DELETIONS, "Applying remote deletions...")
+ val tombstonedIds =
+ notebookSyncService.applyRemoteDeletions(webdavClient, TOMBSTONE_MAX_AGE_DAYS)
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, PROGRESS_SYNCING_NOTEBOOKS, "Syncing local notebooks...")
+ val preDownloadNotebookIds =
+ notebookReconciliationService.syncExistingNotebooks(webdavClient)
+ val notebooksSynced = preDownloadNotebookIds.size
+ reporter.beginStep(SyncStep.DOWNLOADING_NEW, PROGRESS_DOWNLOADING_NEW, "Downloading new notebooks...")
+ val notebooksDownloaded = notebookSyncService.downloadNewNotebooks(
+ webdavClient,
+ tombstonedIds,
+ settings,
+ preDownloadNotebookIds
+ )
+ reporter.beginStep(SyncStep.UPLOADING_DELETIONS, PROGRESS_UPLOADING_DELETIONS, "Uploading deletions...")
+ val notebooksDeleted = notebookSyncService.detectAndUploadLocalDeletions(
+ webdavClient,
+ settings,
+ preDownloadNotebookIds
+ )
+ reporter.beginStep(SyncStep.FINALIZING, PROGRESS_FINALIZING, "Finalizing...")
+ updateSyncedNotebookIds()
+ val duration = System.currentTimeMillis() - startTime
+ val summary =
+ SyncSummary(notebooksSynced, notebooksDownloaded, notebooksDeleted, duration)
+ sLog.i(TAG, "Full sync completed in ${duration}ms")
+ reporter.finishSuccess(summary)
+ SyncResult.Success
+ } catch (e: PreconditionFailedException) {
+ sLog.w(TAG, "Conflict during sync: ${e.message}")
+ reporter.finishError(SyncError.CONFLICT, canRetry = true)
+ SyncResult.Failure(SyncError.CONFLICT)
+ } catch (e: IOException) {
+ sLog.e(TAG, "Network error during sync: ${e.message}")
+ reporter.finishError(SyncError.NETWORK_ERROR, canRetry = true)
+ SyncResult.Failure(SyncError.NETWORK_ERROR)
+ } catch (e: Exception) {
+ sLog.e(TAG, "Unexpected error during sync: ${e.message}\n${e.stackTraceToString()}")
+ reporter.finishError(SyncError.UNKNOWN_ERROR, canRetry = false)
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ } finally {
+ syncMutex.unlock()
+ }
+ }.also {
+ if (it is SyncResult.Success) {
+ delay(SUCCESS_STATE_AUTO_RESET_MS)
+ if (reporter.state.value is SyncState.Success) reporter.reset()
+ }
+ }
+
+ suspend fun syncNotebook(notebookId: String): SyncResult = withContext(ioDispatcher) {
+ if (syncMutex.isLocked) {
+ sLog.i(TAG, "Full sync in progress, skipping per-notebook sync for $notebookId")
+ return@withContext SyncResult.Success
+ }
+ val settings = credentialManager.settings.value
+ if (!settings.syncEnabled) return@withContext SyncResult.Success
+ if (!syncPreflightService.checkWifiConstraint()) {
+ sLog.i(TAG, "WiFi-only sync enabled but not on WiFi, skipping notebook sync")
+ return@withContext SyncResult.Success
+ }
+ val credentials = credentialManager.getCredentials()
+ ?: return@withContext SyncResult.Failure(SyncError.AUTH_ERROR)
+ val webdavClient =
+ webDavClientFactory.create(settings.serverUrl, credentials.first, credentials.second)
+ return@withContext notebookReconciliationService.syncNotebook(notebookId, webdavClient)
+ }
+
+ suspend fun syncFromPageId(pageId: String) {
+ val settings = credentialManager.settings.value
+ if (!settings.syncEnabled || !settings.syncOnNoteClose) return
+ try {
+ val pageEntity = appRepository.pageRepository.getById(pageId) ?: return
+ pageEntity.notebookId?.let { notebookId ->
+ sLog.i("EditorSync", "Auto-syncing notebook $notebookId on page close")
+ syncNotebook(notebookId)
+ }
+ } catch (e: Exception) {
+ sLog.e("EditorSync", "Auto-sync failed: ${e.message}")
+ }
+ }
+
+ suspend fun uploadDeletion(notebookId: String): SyncResult = withContext(ioDispatcher) {
+ return@withContext try {
+ val settings = credentialManager.settings.value
+ if (!settings.syncEnabled) return@withContext SyncResult.Success
+ if (!syncPreflightService.checkWifiConstraint()) return@withContext SyncResult.Success
+ val credentials =
+ credentialManager.getCredentials() ?: return@withContext SyncResult.Failure(
+ SyncError.AUTH_ERROR
+ )
+ val webdavClient =
+ webDavClientFactory.create(
+ settings.serverUrl,
+ credentials.first,
+ credentials.second
+ )
+ val notebookPath = SyncPaths.notebookDir(notebookId)
+ if (webdavClient.exists(notebookPath)) {
+ webdavClient.delete(notebookPath)
+ }
+ webdavClient.putFile(
+ SyncPaths.tombstone(notebookId),
+ ByteArray(0),
+ "application/octet-stream"
+ )
+ val updatedSyncedIds = settings.syncedNotebookIds - notebookId
+ credentialManager.updateSettings { it.copy(syncedNotebookIds = updatedSyncedIds) }
+ SyncResult.Success
+ } catch (e: Exception) {
+ sLog.e(TAG, "Failed to upload deletion: ${e.message}")
+ SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ }
+
+ suspend fun forceUploadAll(): SyncResult = withContext(ioDispatcher) {
+ if (!syncMutex.tryLock()) {
+ sLog.w(TAG, "Sync already in progress, skipping force upload")
+ return@withContext SyncResult.Failure(SyncError.SYNC_IN_PROGRESS)
+ }
+ return@withContext try {
+ syncForceService.forceUploadAll()
+ } finally {
+ syncMutex.unlock()
+ }
+ }
+
+ suspend fun forceDownloadAll(): SyncResult = withContext(ioDispatcher) {
+ if (!syncMutex.tryLock()) {
+ sLog.w(TAG, "Sync already in progress, skipping force download")
+ return@withContext SyncResult.Failure(SyncError.SYNC_IN_PROGRESS)
+ }
+ return@withContext try {
+ syncForceService.forceDownloadAll()
+ } finally {
+ syncMutex.unlock()
+ }
+ }
+
+ private fun updateSyncedNotebookIds() {
+ val currentNotebookIds = appRepository.bookRepository.getAll().map { it.id }.toSet()
+ credentialManager.updateSettings { it.copy(syncedNotebookIds = currentNotebookIds) }
+ }
+
+ companion object {
+ private const val TAG = "SyncOrchestrator"
+ 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
+ private const val SUCCESS_STATE_AUTO_RESET_MS = 3000L
+ private const val CLOCK_SKEW_THRESHOLD_MS = 30_000L
+ private const val TOMBSTONE_MAX_AGE_DAYS = 90L
+
+ // Shared across all call sites (UI, worker, editor) to prevent parallel sync jobs.
+ private val syncMutex = Mutex()
+ }
+}
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface SyncOrchestratorEntryPoint {
+ fun syncOrchestrator(): SyncOrchestrator
+ fun kvProxy(): KvProxy
+ fun credentialManager(): CredentialManager
+ fun snackDispatcher(): com.ethran.notable.ui.SnackDispatcher
+}
+
+sealed class SyncResult {
+ data object Success : SyncResult()
+ data class Failure(val error: SyncError) : SyncResult()
+}
+
+enum class SyncError { NETWORK_ERROR, AUTH_ERROR, CONFIG_ERROR, CLOCK_SKEW, WIFI_REQUIRED, SYNC_IN_PROGRESS, CONFLICT, UNKNOWN_ERROR }
+sealed class SyncState {
+ data object Idle : SyncState()
+ data class Syncing(
+ val currentStep: SyncStep,
+ val stepProgress: Float,
+ val details: String,
+ val item: ItemProgress? = null
+ ) : SyncState()
+
+ data class Success(val summary: SyncSummary) : SyncState()
+ data class Error(val error: SyncError, val step: SyncStep, val canRetry: Boolean) : SyncState()
+}
+
+data class ItemProgress(
+ val index: Int,
+ val total: Int,
+ val name: String
+)
+
+enum class SyncStep { INITIALIZING, SYNCING_FOLDERS, APPLYING_DELETIONS, SYNCING_NOTEBOOKS, DOWNLOADING_NEW, UPLOADING_DELETIONS, FINALIZING }
+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/SyncPaths.kt b/app/src/main/java/com/ethran/notable/sync/SyncPaths.kt
new file mode 100644
index 00000000..28857b4e
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncPaths.kt
@@ -0,0 +1,42 @@
+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/SyncPorts.kt b/app/src/main/java/com/ethran/notable/sync/SyncPorts.kt
new file mode 100644
index 00000000..a252b5af
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncPorts.kt
@@ -0,0 +1,30 @@
+package com.ethran.notable.sync
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Inject
+import javax.inject.Singleton
+
+interface WebDavClientFactoryPort {
+ // Abstraction used by sync flow to avoid direct dependency on WebDAVClient construction.
+ fun create(serverUrl: String, username: String, password: String): WebDAVClient
+}
+
+@Singleton
+class WebDavClientFactoryAdapter @Inject constructor() : WebDavClientFactoryPort {
+ override fun create(serverUrl: String, username: String, password: String): WebDAVClient {
+ return WebDAVClient(serverUrl, username, password)
+ }
+}
+
+@Suppress("unused")
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class SyncPortsModule {
+ // Hilt consumes this binding at compile time; no explicit call site in app code.
+ @Binds
+ abstract fun bindWebDavClientFactory(impl: WebDavClientFactoryAdapter): WebDavClientFactoryPort
+}
+
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncPreflightService.kt b/app/src/main/java/com/ethran/notable/sync/SyncPreflightService.kt
new file mode 100644
index 00000000..ce6bfd62
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncPreflightService.kt
@@ -0,0 +1,39 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SyncPreflightService @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val credentialManager: CredentialManager
+) {
+ fun checkWifiConstraint(): Boolean {
+ val settings = credentialManager.settings.value
+ return if (settings.wifiOnly && !ConnectivityChecker(context).isUnmeteredConnected()) {
+ SyncLogger.i("SyncPreflightService", "WiFi-only sync enabled but not on WiFi, skipping")
+ false
+ } else {
+ true
+ }
+ }
+
+ fun checkClockSkew(webdavClient: WebDAVClient): Long? {
+ val serverTime = webdavClient.getServerTime() ?: return null
+ return System.currentTimeMillis() - serverTime
+ }
+
+ 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())
+ }
+ }
+}
diff --git a/app/src/main/java/com/ethran/notable/sync/SyncProgressReporter.kt b/app/src/main/java/com/ethran/notable/sync/SyncProgressReporter.kt
new file mode 100644
index 00000000..6bf16618
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncProgressReporter.kt
@@ -0,0 +1,90 @@
+package com.ethran.notable.sync
+
+import com.ethran.notable.di.ApplicationScope
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+import javax.inject.Singleton
+
+interface SyncProgressReporter {
+ val state: StateFlow
+
+ fun beginStep(step: SyncStep, stepProgress: Float, details: String)
+ fun beginItem(index: Int, total: Int, name: String)
+ fun endItem()
+ fun finishSuccess(summary: SyncSummary)
+ fun finishError(error: SyncError, canRetry: Boolean)
+ fun reset()
+}
+
+@Singleton
+class SyncProgressReporterImpl @Inject constructor(
+ @param:ApplicationScope private val scope: CoroutineScope
+) : SyncProgressReporter {
+
+ private val _state = MutableStateFlow(SyncState.Idle)
+ override val state: StateFlow = _state.asStateFlow()
+
+ override fun beginStep(step: SyncStep, stepProgress: Float, details: String) {
+ _state.value = SyncState.Syncing(
+ currentStep = step,
+ stepProgress = stepProgress,
+ details = details,
+ item = null
+ )
+ }
+
+ override fun beginItem(index: Int, total: Int, name: String) {
+ _state.update { current ->
+ when (current) {
+ is SyncState.Syncing -> current.copy(item = ItemProgress(index, total, name))
+ else -> current
+ }
+ }
+ }
+
+ override fun endItem() {
+ _state.update { current ->
+ when (current) {
+ is SyncState.Syncing -> current.copy(item = null)
+ else -> current
+ }
+ }
+ }
+
+ override fun finishSuccess(summary: SyncSummary) {
+ _state.value = SyncState.Success(summary)
+ }
+
+ override fun finishError(error: SyncError, canRetry: Boolean) {
+ val step = (_state.value as? SyncState.Syncing)?.currentStep ?: SyncStep.INITIALIZING
+ _state.value = SyncState.Error(error, step, canRetry)
+ }
+
+ override fun reset() {
+ _state.value = SyncState.Idle
+ }
+}
+
+@Suppress("unused")
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class SyncProgressReporterModule {
+ @Binds
+ abstract fun bindSyncProgressReporter(impl: SyncProgressReporterImpl): SyncProgressReporter
+}
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface SyncProgressReporterEntryPoint {
+ fun syncProgressReporter(): SyncProgressReporter
+}
+
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..9711e60d
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncScheduler.kt
@@ -0,0 +1,126 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import java.util.UUID
+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 MIN_PERIODIC_SYNC_INTERVAL_MINUTES = 15L
+
+ /**
+ * Reconcile periodic sync schedule against persisted sync settings.
+ */
+ fun reconcilePeriodicSync(
+ context: Context,
+ settings: SyncSettings
+ ) {
+ if (settings.syncEnabled && settings.autoSync) {
+ enablePeriodicSync(
+ context = context,
+ intervalMinutes = settings.syncInterval.toLong(),
+ wifiOnly = settings.wifiOnly
+ )
+ return
+ }
+ disablePeriodicSync(context)
+ }
+
+ /**
+ * 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 = MIN_PERIODIC_SYNC_INTERVAL_MINUTES,
+ wifiOnly: Boolean = false
+ ) {
+ val safeIntervalMinutes = intervalMinutes.coerceAtLeast(MIN_PERIODIC_SYNC_INTERVAL_MINUTES)
+
+ // 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 = safeIntervalMinutes,
+ repeatIntervalTimeUnit = TimeUnit.MINUTES
+ )
+ .setInputData(
+ Data.Builder()
+ .putString(INPUT_KEY_SYNC_TYPE, DEFAULT_SYNC_TYPE)
+ .putString(INPUT_KEY_SYNC_TRIGGER, SYNC_TRIGGER_PERIODIC)
+ .build()
+ )
+ .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
+ * @param syncType The specific sync action ("syncAll", "forceUpload", "forceDownload", etc.)
+ * @param data Optional extra data (like notebookId)
+ */
+ fun triggerImmediateSync(
+ context: Context,
+ syncType: String = "syncAll",
+ data: Map = emptyMap()
+ ): UUID {
+ val builder = Data.Builder()
+ .putString(INPUT_KEY_SYNC_TYPE, syncType)
+ .putString(INPUT_KEY_SYNC_TRIGGER, SYNC_TRIGGER_IMMEDIATE)
+ for ((k, v) in data) {
+ builder.putString(k, v)
+ }
+ val syncRequest = OneTimeWorkRequestBuilder()
+ .setInputData(builder.build())
+ .build()
+ val workSuffix = data.entries
+ .sortedBy { it.key }
+ .joinToString(separator = "-") { "${it.key}:${it.value}" }
+ .ifEmpty { "default" }
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ /* uniqueWorkName = */ "${SyncWorker.WORK_NAME}-immediate-$syncType-$workSuffix",
+ /* existingWorkPolicy = */ ExistingWorkPolicy.REPLACE,
+ /* work = */ syncRequest
+ )
+ return syncRequest.id
+ }
+
+ private const val INPUT_KEY_SYNC_TYPE = "sync_type"
+ private const val INPUT_KEY_SYNC_TRIGGER = "sync_trigger"
+ private const val DEFAULT_SYNC_TYPE = "syncAll"
+ private const val SYNC_TRIGGER_PERIODIC = "periodic"
+ private const val SYNC_TRIGGER_IMMEDIATE = "immediate"
+}
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..ff5de5a0
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/SyncWorker.kt
@@ -0,0 +1,194 @@
+package com.ethran.notable.sync
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.ethran.notable.R
+import com.ethran.notable.ui.SnackConf
+import com.ethran.notable.ui.SnackDispatcher
+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, SyncOrchestratorEntryPoint::class.java
+ )
+
+ val credentialManager = entryPoint.credentialManager()
+ val syncSettings = credentialManager.settings.value
+
+ // Check if sync is enabled
+ if (!syncSettings.syncEnabled) {
+ Log.i(TAG, "Sync disabled in settings, skipping")
+ return Result.success()
+ }
+
+ // Check WiFi-only constraint
+ if (syncSettings.wifiOnly && !connectivityChecker.isUnmeteredConnected()) {
+ Log.i(TAG, "WiFi-only sync enabled but not on unmetered network, skipping")
+ return Result.success()
+ }
+
+ // Check if we have credentials
+ if (!credentialManager.hasCredentials()) {
+ Log.w(TAG, "No credentials stored, skipping sync")
+ return Result.success()
+ }
+
+ val syncType = inputData.getString("sync_type") ?: "syncAll"
+ val syncTrigger = inputData.getString("sync_trigger")
+ val isPeriodicSync = syncTrigger == SYNC_TRIGGER_PERIODIC
+
+ // Perform sync based on type
+ return try {
+ if (isPeriodicSync) {
+ showSyncSnack(R.string.sync_scheduled_started)
+ }
+
+ val result = when (syncType) {
+ "syncAll" -> entryPoint.syncOrchestrator().syncAllNotebooks()
+ "forceUpload" -> entryPoint.syncOrchestrator().forceUploadAll()
+ "forceDownload" -> entryPoint.syncOrchestrator().forceDownloadAll()
+ "uploadDeletion" -> {
+ val notebookId = inputData.getString("notebookId") ?: return Result.failure()
+ entryPoint.syncOrchestrator().uploadDeletion(notebookId)
+ }
+
+ "syncNotebook" -> {
+ val notebookId = inputData.getString("notebookId") ?: return Result.failure()
+ entryPoint.syncOrchestrator().syncNotebook(notebookId)
+ }
+
+ "syncFromPageId" -> {
+ val pageId = inputData.getString("pageId") ?: return Result.failure()
+ entryPoint.syncOrchestrator().syncFromPageId(pageId)
+ SyncResult.Success // syncFromPageId doesn't return a result, so we wrap it
+ }
+
+ else -> SyncResult.Failure(SyncError.UNKNOWN_ERROR)
+ }
+ when (result) {
+ is SyncResult.Success -> {
+ Log.i(TAG, "Sync $syncType completed successfully")
+ Result.success(workDataOf("success" to true))
+ }
+
+ is SyncResult.Failure -> {
+ val errorStr = result.error.name
+ 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(
+ workDataOf(
+ "success" to false,
+ "error" to errorStr
+ )
+ )
+ }
+
+ SyncError.NETWORK_ERROR -> {
+ Log.e(TAG, "Network error during sync")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure(
+ workDataOf(
+ "success" to false,
+ "error" to errorStr
+ )
+ )
+ }
+ }
+
+ SyncError.AUTH_ERROR,
+ SyncError.CONFIG_ERROR,
+ SyncError.CLOCK_SKEW,
+ SyncError.WIFI_REQUIRED,
+ SyncError.CONFLICT -> {
+ Log.w(TAG, "Sync skipped (non-retryable): ${result.error}")
+ Result.success(
+ workDataOf(
+ "success" to false,
+ "error" to errorStr
+ )
+ )
+ }
+
+ else -> {
+ Log.e(TAG, "Sync failed: ${result.error}")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure(
+ workDataOf(
+ "success" to false,
+ "error" to errorStr
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error in SyncWorker: ${e.message}")
+ if (runAttemptCount < MAX_RETRY_ATTEMPTS) {
+ Result.retry()
+ } else {
+ Result.failure(
+ workDataOf(
+ "success" to false,
+ "error" to "UNKNOWN_EXCEPTION"
+ )
+ )
+ }
+ } finally {
+ if (isPeriodicSync)
+ showSyncSnack(R.string.sync_scheduled_completed)
+ else
+ showSyncSnack(R.string.sync_completed_successfully)
+ }
+ }
+
+ private fun showSyncSnack(textResId: Int) {
+ val entryPoint = EntryPointAccessors.fromApplication(
+ applicationContext, SyncOrchestratorEntryPoint::class.java
+ )
+ entryPoint.snackDispatcher().showOrUpdateSnack(
+ SnackConf(
+ text = applicationContext.getString(textResId),
+ duration = 3000
+ )
+ )
+ }
+
+ companion object {
+ private const val TAG = "SyncWorker"
+ private const val MAX_RETRY_ATTEMPTS = 3
+ private const val SYNC_TRIGGER_PERIODIC = "periodic"
+
+ /**
+ * 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..b3a95f6b
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt
@@ -0,0 +1,683 @@
+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?)
+
+data class DownloadedFile(
+ val content: ByteArray,
+ val etag: String?
+)
+
+class PreconditionFailedException(message: String) : IOException(message)
+
+/**
+ * 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",
+ ifMatch: String? = null
+ ) {
+ val url = buildUrl(path)
+ val mediaType = contentType.toMediaType()
+ val requestBody = content.toRequestBody(mediaType)
+
+ val requestBuilder = Request.Builder()
+ .url(url)
+ .put(requestBody)
+ .header("Authorization", credentials)
+
+ ifMatch?.let { requestBuilder.header("If-Match", it) }
+
+ val request = requestBuilder.build()
+
+ client.newCall(request).execute().use { response ->
+ if (response.code == HttpURLConnection.HTTP_PRECON_FAILED) {
+ throw PreconditionFailedException(
+ "Precondition failed for $path: ${response.code} ${response.message}"
+ )
+ }
+ 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",
+ ifMatch: String? = null
+ ) {
+ if (!localFile.exists()) {
+ throw IOException("Local file does not exist: ${localFile.absolutePath}")
+ }
+ putFile(path, localFile.readBytes(), contentType, ifMatch)
+ }
+
+ /**
+ * 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 {
+ return getFileWithMetadata(path).content
+ }
+
+ fun getFileWithMetadata(path: String): DownloadedFile {
+ 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}")
+ }
+ val content = response.body?.bytes() ?: throw IOException("Empty response body")
+ return DownloadedFile(content = content, etag = response.header("ETag"))
+ }
+ }
+
+ /**
+ * 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/sync/serializers/DeletionsSerializer.kt b/app/src/main/java/com/ethran/notable/sync/serializers/DeletionsSerializer.kt
new file mode 100644
index 00000000..97b09464
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/serializers/DeletionsSerializer.kt
@@ -0,0 +1,3 @@
+package com.ethran.notable.sync.serializers
+// Legacy deletions.json migration has been removed.
+// Tombstones are the only supported deletion mechanism.
diff --git a/app/src/main/java/com/ethran/notable/sync/serializers/FolderSerializer.kt b/app/src/main/java/com/ethran/notable/sync/serializers/FolderSerializer.kt
new file mode 100644
index 00000000..0a2c3487
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/serializers/FolderSerializer.kt
@@ -0,0 +1,119 @@
+package com.ethran.notable.sync.serializers
+
+import com.ethran.notable.data.db.Folder
+import kotlinx.serialization.Serializable
+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/serializers/NotebookSerializer.kt b/app/src/main/java/com/ethran/notable/sync/serializers/NotebookSerializer.kt
new file mode 100644
index 00000000..02f9b2ee
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/sync/serializers/NotebookSerializer.kt
@@ -0,0 +1,312 @@
+package com.ethran.notable.sync.serializers
+
+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.decodeStrokePoints
+import com.ethran.notable.data.db.encodeStrokePoints
+import com.ethran.notable.editor.utils.Pen
+import kotlinx.serialization.Serializable
+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 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/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..0076ce72 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
@@ -41,6 +41,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -55,10 +56,11 @@ import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import com.ethran.notable.R
import com.ethran.notable.data.AppRepository
-import com.ethran.notable.data.model.BackgroundType
import com.ethran.notable.data.db.Folder
+import com.ethran.notable.data.model.BackgroundType
import com.ethran.notable.io.ExportEngine
import com.ethran.notable.io.getLinkedFilesDir
+import com.ethran.notable.sync.SyncScheduler
import com.ethran.notable.ui.LocalSnackContext
import com.ethran.notable.ui.SnackConf
import com.ethran.notable.ui.components.BreadCrumb
@@ -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,19 @@ fun NotebookConfigDialog(
}
showDeleteDialog = false
onClose()
+
+ // Queue remote deletion in background so it is independent from this view lifecycle.
+ scope.launch {
+ snackManager.runWithSnack("Deleting notebook...", 3000) {
+ SyncScheduler.triggerImmediateSync(
+ context = context.applicationContext,
+ syncType = "uploadDeletion",
+ data = mapOf("notebookId" to bookId)
+ )
+ log.i("Queued notebook deletion upload for $bookId")
+ "Notebook deleted. Sync queued."
+ }
+ }
},
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 a0b9674d..4e50ca51 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
@@ -71,6 +71,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())
@@ -164,17 +166,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/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/ethran/notable/ui/viewmodels/SettingsViewModel.kt
index 3779c185..be579264 100644
--- a/app/src/main/java/com/ethran/notable/ui/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/com/ethran/notable/ui/viewmodels/SettingsViewModel.kt
@@ -11,15 +11,31 @@ import com.ethran.notable.R
import com.ethran.notable.data.datastore.AppSettings
import com.ethran.notable.data.datastore.GlobalAppSettings
import com.ethran.notable.data.db.KvProxy
+import com.ethran.notable.di.ApplicationScope
+import com.ethran.notable.sync.CredentialManager
+import com.ethran.notable.sync.SyncLogger
+import com.ethran.notable.sync.SyncOrchestrator
+import com.ethran.notable.sync.SyncProgressReporter
+import com.ethran.notable.sync.SyncResult
+import com.ethran.notable.sync.SyncScheduler
+import com.ethran.notable.sync.SyncSettings
+import com.ethran.notable.sync.SyncState
+import com.ethran.notable.sync.WebDAVClient
+import com.ethran.notable.ui.SnackConf
+import com.ethran.notable.ui.SnackDispatcher
import com.ethran.notable.utils.isLatestVersion
import com.ethran.notable.data.events.AppEventBus
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import javax.inject.Inject
-
data class GestureRowModel(
val titleRes: Int,
val currentValue: AppSettings.GestureAction?,
@@ -27,13 +43,43 @@ data class GestureRowModel(
val onUpdate: (AppSettings.GestureAction?) -> Unit
)
+sealed class SyncConnectionStatus {
+ data object Success : SyncConnectionStatus()
+ data object Failed : SyncConnectionStatus()
+ data class ClockSkew(val seconds: Long) : SyncConnectionStatus()
+}
+
+data class SyncSettingsUiState(
+ val serverUrl: String = "",
+ val username: String = "",
+ val password: String = "",
+ val savedUsername: String = "",
+ val savedPassword: String = "",
+ val isPasswordSaved: Boolean = false,
+ val passwordVisible: Boolean = false,
+ val testingConnection: Boolean = false,
+ val connectionStatus: SyncConnectionStatus? = null,
+ val syncLogs: List = emptyList(),
+ val syncState: SyncState = SyncState.Idle,
+ val showForceUploadConfirm: Boolean = false,
+ val showForceDownloadConfirm: Boolean = false,
+ val syncSettings: SyncSettings = SyncSettings()
+) {
+ val credentialsChanged: Boolean
+ get() = username != savedUsername || (password.isNotEmpty() && password != savedPassword)
+}
@HiltViewModel
class SettingsViewModel @Inject constructor(
+ @param:ApplicationContext private val appContext: Context,
private val kvProxy: KvProxy,
+ private val credentialManager: CredentialManager,
+ private val syncOrchestrator: SyncOrchestrator,
+ private val syncProgressReporter: SyncProgressReporter,
+ private val snackDispatcher: SnackDispatcher,
private val appEventBus: AppEventBus,
+ @param:ApplicationScope private val appScope: CoroutineScope
) : ViewModel() {
- companion object {}
// We use the GlobalAppSettings object directly.
val settings: AppSettings
@@ -42,9 +88,51 @@ class SettingsViewModel @Inject constructor(
var isLatestVersion: Boolean by mutableStateOf(true)
private set
+ var syncUiState by mutableStateOf(SyncSettingsUiState())
+ private set
+
+ init {
+ // Observe logs
+ viewModelScope.launch {
+ SyncLogger.logs.collect { logs ->
+ syncUiState = syncUiState.copy(syncLogs = logs)
+ }
+ }
+
+ // Observe sync engine state
+ viewModelScope.launch {
+ syncProgressReporter.state.collect { state ->
+ syncUiState = syncUiState.copy(syncState = state)
+ }
+ }
+
+ // Observe credential manager settings (single source of truth)
+ viewModelScope.launch {
+ credentialManager.settings.collect { settings ->
+ syncUiState = syncUiState.copy(
+ syncSettings = settings,
+ serverUrl = settings.serverUrl,
+ username = settings.username,
+ savedUsername = settings.username
+ )
+ }
+ }
+
+ // Load initial password state (don't load actual password into memory)
+ viewModelScope.launch(Dispatchers.IO) {
+ val hasPassword = credentialManager.getPassword() != null
+ withContext(Dispatchers.Main) {
+ syncUiState = syncUiState.copy(
+ isPasswordSaved = hasPassword,
+ password = "",
+ savedPassword = ""
+ )
+ }
+ }
+ }
+
/**
* Checks if the app is the latest version.
- * Uses Dispatchers.IO for the network/disk call.
*/
fun checkUpdate(context: Context, force: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) {
@@ -55,21 +143,204 @@ class SettingsViewModel @Inject constructor(
}
}
- /**
- * The ViewModel handles the side effects:
- * 1. Updating the global state for immediate UI feedback.
- * 2. Persisting to the database in a background scope.
- */
fun updateSettings(newSettings: AppSettings) {
- // 1. Update the Global state (immediate recomposition)
GlobalAppSettings.update(newSettings)
-
- // 2. Persist to DB in the background
viewModelScope.launch(Dispatchers.IO) {
kvProxy.setKv(APP_SETTINGS_KEY, newSettings, AppSettings.serializer())
}
}
+ // ----------------- //
+ // Sync Settings
+ // ----------------- //
+
+ fun onServerUrlChanged(serverUrl: String) {
+ credentialManager.updateSettings { it.copy(serverUrl = serverUrl) }
+ }
+
+ fun onUsernameChanged(username: String) {
+ syncUiState = syncUiState.copy(username = username)
+ }
+
+ fun onPasswordChanged(password: String) {
+ syncUiState = syncUiState.copy(password = password)
+ }
+
+ fun onTogglePasswordVisibility() {
+ syncUiState = syncUiState.copy(passwordVisible = !syncUiState.passwordVisible)
+ }
+
+ fun onSaveCredentials() {
+ val username = syncUiState.username
+ val password = syncUiState.password
+
+ // If password is empty but saved, we only update username if it changed
+ if (password.isEmpty() && syncUiState.isPasswordSaved) {
+ if (username != syncUiState.savedUsername) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val currentPassword = credentialManager.getPassword() ?: ""
+ credentialManager.saveCredentials(username, currentPassword)
+ withContext(Dispatchers.Main) {
+ syncUiState = syncUiState.copy(savedUsername = username)
+ SyncLogger.i("Settings", "Username updated to: $username")
+ snackDispatcher.showOrUpdateSnack(
+ SnackConf(text = "Credentials updated", duration = 3000)
+ )
+ }
+ }
+ }
+ return
+ }
+
+ if (username.isBlank() || password.isBlank()) return
+
+ credentialManager.saveCredentials(username, password)
+ syncUiState = syncUiState.copy(
+ savedUsername = username,
+ savedPassword = password,
+ isPasswordSaved = true,
+ password = "" // Clear after saving for security
+ )
+
+ SyncLogger.i("Settings", "Credentials saved for user: $username")
+ snackDispatcher.showOrUpdateSnack(SnackConf(text = "Credentials saved", duration = 3000))
+ }
+
+ fun onTestConnection() {
+ val serverUrl = syncUiState.serverUrl
+ val username = syncUiState.username
+ val password = syncUiState.password
+ if (serverUrl.isBlank() || username.isBlank()) return
+
+ syncUiState = syncUiState.copy(testingConnection = true, connectionStatus = null)
+ viewModelScope.launch(Dispatchers.IO) {
+ // If password field is empty, use the saved one
+ val passwordToUse = if (password.isEmpty()) {
+ credentialManager.getPassword() ?: ""
+ } else {
+ password
+ }
+
+ val (connected, clockSkewMs) = WebDAVClient.testConnection(
+ serverUrl, username, passwordToUse
+ )
+ withContext(Dispatchers.Main) {
+ val status = when {
+ !connected -> SyncConnectionStatus.Failed
+ clockSkewMs != null && kotlin.math.abs(clockSkewMs) > 30_000L -> SyncConnectionStatus.ClockSkew(
+ clockSkewMs / 1000
+ )
+
+ else -> SyncConnectionStatus.Success
+ }
+ syncUiState = syncUiState.copy(
+ testingConnection = false, connectionStatus = status
+ )
+ }
+ }
+ }
+
+ fun onSyncEnabledChanged(isChecked: Boolean) {
+ credentialManager.updateSettings { it.copy(syncEnabled = isChecked) }
+ updatePeriodicSyncSchedule()
+ }
+
+ fun onAutoSyncChanged(isChecked: Boolean) {
+ credentialManager.updateSettings { it.copy(autoSync = isChecked) }
+ updatePeriodicSyncSchedule()
+ }
+
+ fun onSyncIntervalChanged(minutes: Int) {
+ credentialManager.updateSettings {
+ it.copy(syncInterval = minutes.coerceAtLeast(15))
+ }
+ updatePeriodicSyncSchedule()
+ }
+
+ fun onSyncOnNoteCloseChanged(isChecked: Boolean) {
+ credentialManager.updateSettings { it.copy(syncOnNoteClose = isChecked) }
+ }
+
+ fun onWifiOnlyChanged(isChecked: Boolean) {
+ credentialManager.updateSettings { it.copy(wifiOnly = isChecked) }
+ updatePeriodicSyncSchedule()
+ }
+
+ fun onManualSync() {
+ runSyncWithSnack(
+ textDuring = "Sync initialized...",
+ successMessage = "Sync completed successfully"
+ ) {
+ val result = syncOrchestrator.syncAllNotebooks()
+ if (result is SyncResult.Success) {
+ val timestamp =
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
+ credentialManager.updateSettings { it.copy(lastSyncTime = timestamp) }
+ }
+ result
+ }
+ }
+
+ fun onForceUploadRequested(show: Boolean) {
+ syncUiState = syncUiState.copy(showForceUploadConfirm = show)
+ }
+
+ fun onForceDownloadRequested(show: Boolean) {
+ syncUiState = syncUiState.copy(showForceDownloadConfirm = show)
+ }
+
+ fun onConfirmForceUpload() {
+ syncUiState = syncUiState.copy(showForceUploadConfirm = false)
+ runSyncWithSnack(
+ textDuring = "Force upload started...",
+ successMessage = "Force upload complete"
+ ) { syncOrchestrator.forceUploadAll() }
+ }
+
+ fun onConfirmForceDownload() {
+ syncUiState = syncUiState.copy(showForceDownloadConfirm = false)
+ runSyncWithSnack(
+ textDuring = "Force download started...",
+ successMessage = "Force download complete"
+ ) { syncOrchestrator.forceDownloadAll() }
+ }
+
+ private fun runSyncWithSnack(
+ textDuring: String,
+ successMessage: String,
+ action: suspend () -> SyncResult
+ ) {
+ appScope.launch {
+ val snackId = java.util.UUID.randomUUID().toString()
+ snackDispatcher.showOrUpdateSnack(
+ SnackConf(id = snackId, text = textDuring, duration = null)
+ )
+ val message = try {
+ val result = action()
+ if (result is SyncResult.Success) {
+ successMessage
+ } else {
+ val error = (result as? SyncResult.Failure)?.error?.toString() ?: "Unknown"
+ "Sync failed: $error"
+ }
+ } catch (e: Exception) {
+ "Sync failed: ${e.message ?: "Unknown"}"
+ }
+ snackDispatcher.showOrUpdateSnack(
+ SnackConf(id = snackId, text = message, duration = 3000)
+ )
+ }
+ }
+
+ private fun updatePeriodicSyncSchedule() {
+ val settings = credentialManager.settings.value
+ SyncScheduler.reconcilePeriodicSync(appContext, settings)
+ }
+
+ fun onClearSyncLogs() {
+ SyncLogger.clear()
+ }
+
// ----------------- //
// Gesture Settings
// ----------------- //
@@ -84,7 +355,7 @@ class SettingsViewModel @Inject constructor(
(R.string.gestures_two_finger_tap_action),
settings.twoFingerTapAction,
AppSettings.defaultTwoFingerTapAction,
- ) { a -> updateSettings(settings.copy(twoFingerTapAction = a)) },
+ ) { a -> updateSettings(settings.copy(twoFingerTapAction = a)) },
GestureRowModel(
(R.string.gestures_swipe_left_action),
settings.swipeLeftAction,
@@ -120,4 +391,4 @@ class SettingsViewModel @Inject constructor(
)
-}
\ No newline at end of file
+}
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 d9e40a75..9094dcc3 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 711d87a5..18a793ef 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
@@ -3,6 +3,7 @@ package com.ethran.notable.ui.views
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
+import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -67,6 +68,7 @@ import com.ethran.notable.ui.components.GesturesSettings
import com.ethran.notable.ui.theme.InkaTheme
import com.ethran.notable.ui.viewmodels.GestureRowModel
import com.ethran.notable.ui.viewmodels.SettingsViewModel
+import com.ethran.notable.ui.viewmodels.SyncSettingsUiState
import com.ethran.notable.utils.isNext
import kotlinx.coroutines.launch
@@ -84,6 +86,7 @@ fun SettingsView(
) {
val context = LocalContext.current
val settings = viewModel.settings
+ val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.checkUpdate(context, force = false)
@@ -105,7 +108,33 @@ fun SettingsView(
},
onUpdateSettings = { viewModel.updateSettings(it) },
listOfGestures = viewModel.getGestureRows(),
- availableGestures = viewModel.availableGestures
+ availableGestures = viewModel.availableGestures,
+ syncUiState = viewModel.syncUiState,
+ syncCallbacks = SyncSettingsCallbacks(
+ credentials = SyncCredentialsCallbacks(
+ onServerUrlChange = viewModel::onServerUrlChanged,
+ onUsernameChange = viewModel::onUsernameChanged,
+ onPasswordChange = viewModel::onPasswordChanged,
+ onTogglePasswordVisibility = viewModel::onTogglePasswordVisibility,
+ onSaveCredentials = viewModel::onSaveCredentials,
+ ),
+ behavior = SyncBehaviorCallbacks(
+ onToggleSyncEnabled = viewModel::onSyncEnabledChanged,
+ onAutoSyncChanged = viewModel::onAutoSyncChanged,
+ onSyncIntervalChanged = viewModel::onSyncIntervalChanged,
+ onSyncOnCloseChanged = viewModel::onSyncOnNoteCloseChanged,
+ onWifiOnlyChanged = viewModel::onWifiOnlyChanged,
+ ),
+ onTestConnection = viewModel::onTestConnection,
+ onManualSync = viewModel::onManualSync,
+ onClearSyncLogs = viewModel::onClearSyncLogs,
+ danger = SyncDangerCallbacks(
+ onForceUploadRequested = viewModel::onForceUploadRequested,
+ onForceDownloadRequested = viewModel::onForceDownloadRequested,
+ onConfirmForceUpload = viewModel::onConfirmForceUpload,
+ onConfirmForceDownload = viewModel::onConfirmForceDownload,
+ ),
+ )
)
}
@@ -121,12 +150,15 @@ fun SettingsContent(
onUpdateSettings: (AppSettings) -> Unit,
selectedTabInitial: Int = 0,
listOfGestures: List = emptyList(),
- availableGestures: List> = emptyList()
+ availableGestures: List> = emptyList(),
+ syncUiState: SyncSettingsUiState = SyncSettingsUiState(),
+ syncCallbacks: SyncSettingsCallbacks = SyncSettingsCallbacks(),
) {
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)
)
@@ -156,7 +188,12 @@ fun SettingsContent(
settings, onUpdateSettings, listOfGestures, availableGestures
)
- 2 -> DebugSettings(settings, onUpdateSettings, goToWelcome, goToSystemInfo)
+ 2 -> SyncSettings(
+ state = syncUiState,
+ callbacks = syncCallbacks,
+ )
+
+ 3 -> DebugSettings(settings, onUpdateSettings, goToWelcome, goToSystemInfo)
}
}
@@ -172,7 +209,8 @@ fun SettingsContent(
.fillMaxWidth()
)
UpdateActions(
- isLatestVersion = isLatestVersion, onCheckUpdate = onCheckUpdate,
+ isLatestVersion = isLatestVersion,
+ onCheckUpdate = onCheckUpdate,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 30.dp, vertical = 8.dp)
@@ -231,8 +269,7 @@ private fun SettingsTabRow(tabs: List, selectedTab: Int, onTabSelected:
tabs.forEachIndexed { index, title ->
Tab(selected = selectedTab == index, onClick = { onTabSelected(index) }, text = {
Text(
- text = title,
- color = if (selectedTab == index) MaterialTheme.colors.onSurface
+ text = title, color = if (selectedTab == index) MaterialTheme.colors.onSurface
else MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
})
@@ -316,9 +353,6 @@ fun UpdateActions(
}
-
-
-
fun openInBrowser(context: Context, uriString: String, onError: (String) -> Unit) {
val urlIntent = Intent(Intent.ACTION_VIEW, uriString.toUri())
try {
@@ -393,7 +427,34 @@ fun SettingsPreviewDebug() {
goToSystemInfo = {},
onCheckUpdate = {},
onUpdateSettings = {},
- selectedTabInitial = 2
+ selectedTabInitial = 3
+ )
+ }
+}
+@Preview(
+ name = "Light",
+ showBackground = true,
+ device = "spec:width=360dp,height=600dp"
+)
+@Preview(
+ name = "Dark",
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ device = "spec:width=360dp,height=600dp"
+)
+@Composable
+fun SettingsPreviewSync() {
+ InkaTheme {
+ SettingsContent(
+ versionString = "v1.0.0",
+ settings = AppSettings(version = 1),
+ isLatestVersion = true,
+ onBack = {},
+ goToWelcome = {},
+ goToSystemInfo = {},
+ onCheckUpdate = {},
+ onUpdateSettings = {},
+ selectedTabInitial = 2,
)
}
}
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..6b8b492d
--- /dev/null
+++ b/app/src/main/java/com/ethran/notable/ui/views/SyncSettingsTab.kt
@@ -0,0 +1,978 @@
+package com.ethran.notable.ui.views
+
+import android.content.res.Configuration
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.border
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.ArrowDropUp
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.ethran.notable.R
+import com.ethran.notable.sync.SyncLogger
+import com.ethran.notable.sync.SyncSettings
+import com.ethran.notable.sync.SyncState
+import com.ethran.notable.sync.SyncStep
+import com.ethran.notable.ui.components.SettingToggleRow
+import com.ethran.notable.ui.components.SettingsDivider
+import com.ethran.notable.ui.theme.InkaTheme
+import com.ethran.notable.ui.viewmodels.SyncConnectionStatus
+import com.ethran.notable.ui.viewmodels.SyncSettingsUiState
+
+data class SyncCredentialsCallbacks(
+ val onServerUrlChange: (String) -> Unit = {},
+ val onUsernameChange: (String) -> Unit = {},
+ val onPasswordChange: (String) -> Unit = {},
+ val onTogglePasswordVisibility: () -> Unit = {},
+ val onSaveCredentials: () -> Unit = {},
+)
+
+data class SyncBehaviorCallbacks(
+ val onToggleSyncEnabled: (Boolean) -> Unit = {},
+ val onAutoSyncChanged: (Boolean) -> Unit = {},
+ val onSyncIntervalChanged: (Int) -> Unit = {},
+ val onSyncOnCloseChanged: (Boolean) -> Unit = {},
+ val onWifiOnlyChanged: (Boolean) -> Unit = {},
+)
+
+data class SyncDangerCallbacks(
+ val onForceUploadRequested: (Boolean) -> Unit = {},
+ val onForceDownloadRequested: (Boolean) -> Unit = {},
+ val onConfirmForceUpload: () -> Unit = {},
+ val onConfirmForceDownload: () -> Unit = {},
+)
+
+data class SyncSettingsCallbacks(
+ val credentials: SyncCredentialsCallbacks = SyncCredentialsCallbacks(),
+ val behavior: SyncBehaviorCallbacks = SyncBehaviorCallbacks(),
+ val onTestConnection: () -> Unit = {},
+ val onManualSync: () -> Unit = {},
+ val onClearSyncLogs: () -> Unit = {},
+ val danger: SyncDangerCallbacks = SyncDangerCallbacks(),
+)
+
+private val EInkFieldShape = RoundedCornerShape(4.dp)
+private val EInkButtonShape = RoundedCornerShape(8.dp)
+private val EInkFieldBorderWidth = 1.dp
+
+@Composable
+fun SyncSettings(
+ state: SyncSettingsUiState,
+ callbacks: SyncSettingsCallbacks,
+) {
+ val isConfigured by remember(state.isPasswordSaved, state.serverUrl) {
+ derivedStateOf { state.isPasswordSaved && state.serverUrl.isNotEmpty() }
+ }
+ val serverSectionTitle by remember(isConfigured, state.serverUrl) {
+ derivedStateOf {
+ if (isConfigured) "Server: ${state.serverUrl.take(25)}..." else "Connection Setup"
+ }
+ }
+ var showServerConfig by remember { mutableStateOf(!isConfigured) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.sync_title),
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ ConnectionSection(
+ state = state,
+ callbacks = callbacks,
+ sectionTitle = serverSectionTitle,
+ isConfigured = isConfigured,
+ showServerConfig = showServerConfig,
+ onToggleSection = { showServerConfig = !showServerConfig }
+ )
+
+ if (isConfigured) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ SyncBehaviorSection(state = state, callbacks = callbacks)
+
+ if (state.syncSettings.syncEnabled) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ SyncActionsSection(state = state, callbacks = callbacks)
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ var logsExpanded by remember { mutableStateOf(false) }
+ SyncLogsSection(
+ state = state,
+ callbacks = callbacks,
+ isExpanded = logsExpanded,
+ onToggleExpanded = { logsExpanded = !logsExpanded }
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.height(24.dp))
+ MissingConfigurationHint()
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
+@Composable
+private fun ConnectionSection(
+ state: SyncSettingsUiState,
+ callbacks: SyncSettingsCallbacks,
+ sectionTitle: String,
+ isConfigured: Boolean,
+ showServerConfig: Boolean,
+ onToggleSection: () -> Unit,
+) {
+ EInkSection(
+ title = sectionTitle,
+ icon = Icons.Default.Cloud,
+ isExpandable = isConfigured,
+ isExpanded = showServerConfig,
+ onHeaderClick = { if (isConfigured) onToggleSection() }
+ ) {
+ SyncCredentialFields(
+ serverUrl = state.serverUrl,
+ username = state.username,
+ password = state.password,
+ isPasswordSaved = state.isPasswordSaved,
+ passwordVisible = state.passwordVisible,
+ onServerUrlChange = callbacks.credentials.onServerUrlChange,
+ onUsernameChange = callbacks.credentials.onUsernameChange,
+ onPasswordChange = callbacks.credentials.onPasswordChange,
+ onTogglePasswordVisibility = callbacks.credentials.onTogglePasswordVisibility
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ EInkActionButton(
+ text = "Save Credentials",
+ onClick = callbacks.credentials.onSaveCredentials,
+ enabled = state.credentialsChanged && state.username.isNotEmpty(),
+ modifier = Modifier.weight(1f),
+ isBold = true
+ )
+ EInkActionButton(
+ text = if (state.testingConnection) "Testing..." else "Test Connection",
+ onClick = callbacks.onTestConnection,
+ enabled = !state.testingConnection && state.serverUrl.isNotEmpty(),
+ modifier = Modifier.weight(1f),
+ isSecondary = true
+ )
+ }
+
+ state.connectionStatus?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ ConnectionStatusText(it)
+ }
+ }
+}
+
+@Composable
+private fun SyncBehaviorSection(
+ state: SyncSettingsUiState,
+ callbacks: SyncSettingsCallbacks,
+) {
+ EInkSection(title = "Sync Behavior", icon = Icons.Default.Settings) {
+ SyncEnableToggle(state.syncSettings, callbacks.behavior.onToggleSyncEnabled)
+
+ if (state.syncSettings.syncEnabled) {
+ SyncControlToggles(
+ syncSettings = state.syncSettings,
+ onAutoSyncChanged = callbacks.behavior.onAutoSyncChanged,
+ onSyncIntervalChanged = callbacks.behavior.onSyncIntervalChanged,
+ onSyncOnCloseChanged = callbacks.behavior.onSyncOnCloseChanged,
+ onWifiOnlyChanged = callbacks.behavior.onWifiOnlyChanged
+ )
+ }
+ }
+}
+
+@Composable
+private fun SyncActionsSection(
+ state: SyncSettingsUiState,
+ callbacks: SyncSettingsCallbacks,
+) {
+ EInkSection(title = "Manual Actions", icon = Icons.Default.Sync) {
+ ManualSyncButton(
+ syncSettings = state.syncSettings,
+ serverUrl = state.serverUrl,
+ syncState = state.syncState,
+ onManualSync = callbacks.onManualSync
+ )
+
+ LastSyncInfo(lastSyncTime = state.syncSettings.lastSyncTime)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ "DANGER ZONE",
+ style = MaterialTheme.typography.overline,
+ fontWeight = FontWeight.ExtraBold,
+ color = MaterialTheme.colors.onSurface
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ ForceOperationsSection(
+ syncSettings = state.syncSettings,
+ onForceUploadRequested = callbacks.danger.onForceUploadRequested,
+ onForceDownloadRequested = callbacks.danger.onForceDownloadRequested,
+ onConfirmForceUpload = callbacks.danger.onConfirmForceUpload,
+ onConfirmForceDownload = callbacks.danger.onConfirmForceDownload,
+ showForceUploadConfirm = state.showForceUploadConfirm,
+ showForceDownloadConfirm = state.showForceDownloadConfirm
+ )
+ }
+}
+
+@Composable
+private fun LastSyncInfo(lastSyncTime: String?) {
+ val label = lastSyncTime?.takeIf { it.isNotBlank() } ?: "Never"
+ Text(
+ text = "Last sync: $label",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(top = 8.dp, start = 4.dp)
+ )
+}
+
+@Composable
+private fun SyncLogsSection(
+ state: SyncSettingsUiState,
+ callbacks: SyncSettingsCallbacks,
+ isExpanded: Boolean,
+ onToggleExpanded: () -> Unit,
+) {
+ EInkSection(
+ title = "Activity Log",
+ icon = Icons.Default.History,
+ isExpandable = true,
+ isExpanded = isExpanded,
+ onHeaderClick = onToggleExpanded
+ ) {
+ SyncLogViewer(syncLogs = state.syncLogs, onClearLog = callbacks.onClearSyncLogs)
+ }
+}
+
+@Composable
+private fun MissingConfigurationHint() {
+ Text(
+ "Complete the connection setup above to enable sync features.",
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+}
+
+
+@Composable
+fun EInkSection(
+ title: String,
+ icon: ImageVector,
+ isExpandable: Boolean = false,
+ isExpanded: Boolean = true,
+ onHeaderClick: () -> Unit = {},
+ content: @Composable () -> Unit
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = isExpandable) { onHeaderClick() }
+ .padding(vertical = 8.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colors.onSurface
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ title,
+ style = MaterialTheme.typography.subtitle2,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colors.onSurface
+ )
+ if (isExpandable) {
+ Icon(
+ if (isExpanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ tint = MaterialTheme.colors.onSurface
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = isExpanded) {
+ Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) {
+ content()
+ }
+ }
+ SettingsDivider()
+ }
+}
+
+@Composable
+fun ConnectionStatusText(status: SyncConnectionStatus) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (status == SyncConnectionStatus.Success) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colors.onSurface
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+ val text = when (status) {
+ SyncConnectionStatus.Success -> "Connected successfully"
+ SyncConnectionStatus.Failed -> "Connection failed"
+ is SyncConnectionStatus.ClockSkew -> "Clock skew detected (${status.seconds}s)"
+ }
+ Text(
+ text,
+ style = MaterialTheme.typography.caption,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+}
+
+@Composable
+fun SyncCredentialFields(
+ serverUrl: String,
+ username: String,
+ password: String,
+ isPasswordSaved: Boolean,
+ passwordVisible: Boolean,
+ onServerUrlChange: (String) -> Unit,
+ onUsernameChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onTogglePasswordVisibility: () -> Unit
+) {
+ EInkTextField(
+ label = "Server URL",
+ value = serverUrl,
+ onValueChange = onServerUrlChange,
+ placeholder = "https://example.com/dav/"
+ )
+
+ if (serverUrl.isNotEmpty()) {
+ Text(
+ "Path: ${serverUrl.trimEnd('/')}/notable/",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ modifier = Modifier.padding(top = 2.dp, start = 4.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ EInkTextField(
+ label = "Username",
+ value = username,
+ onValueChange = onUsernameChange
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ "Password",
+ style = MaterialTheme.typography.caption,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.surface, EInkFieldShape)
+ .border(EInkFieldBorderWidth, MaterialTheme.colors.onSurface, EInkFieldShape)
+ .padding(start = 12.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(modifier = Modifier.weight(1f)) {
+ if (password.isEmpty() && isPasswordSaved) {
+ Text(
+ "(unchanged)",
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
+ style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp)
+ )
+ }
+ BasicTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
+ singleLine = true,
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ )
+ }
+ IconButton(onClick = onTogglePasswordVisibility) {
+ Icon(
+ if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun EInkTextField(label: String, value: String, onValueChange: (String) -> Unit, placeholder: String = "") {
+ Column {
+ Text(
+ label,
+ style = MaterialTheme.typography.caption,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.surface, EInkFieldShape)
+ .border(EInkFieldBorderWidth, MaterialTheme.colors.onSurface, EInkFieldShape)
+ .padding(12.dp)
+ ) {
+ if (value.isEmpty() && placeholder.isNotEmpty()) {
+ Text(
+ placeholder,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.3f),
+ style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp)
+ )
+ }
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Composable
+fun eInkButtonColors(isSecondary: Boolean = false) = ButtonDefaults.buttonColors(
+ backgroundColor = if (isSecondary) MaterialTheme.colors.onSurface.copy(alpha = 0.1f) else MaterialTheme.colors.onSurface,
+ contentColor = if (isSecondary) MaterialTheme.colors.onSurface else MaterialTheme.colors.surface,
+ disabledBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
+ disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.3f)
+)
+
+@Composable
+fun SyncEnableToggle(
+ syncSettings: SyncSettings, onToggleSyncEnabled: (Boolean) -> Unit
+) {
+ SettingToggleRow(
+ label = "Enable WebDAV Sync",
+ value = syncSettings.syncEnabled,
+ onToggle = onToggleSyncEnabled
+ )
+}
+
+@Composable
+fun SyncControlToggles(
+ syncSettings: SyncSettings,
+ onAutoSyncChanged: (Boolean) -> Unit,
+ onSyncIntervalChanged: (Int) -> Unit,
+ onSyncOnCloseChanged: (Boolean) -> Unit,
+ onWifiOnlyChanged: (Boolean) -> Unit
+) {
+ SettingToggleRow(
+ label = "Auto-sync (every ${syncSettings.syncInterval}m)",
+ value = syncSettings.autoSync,
+ onToggle = onAutoSyncChanged
+ )
+ SyncIntervalSelector(
+ intervalMinutes = syncSettings.syncInterval,
+ onIntervalChanged = onSyncIntervalChanged
+ )
+ SettingToggleRow(
+ label = "Sync when closing notes",
+ value = syncSettings.syncOnNoteClose,
+ onToggle = onSyncOnCloseChanged
+ )
+ SettingToggleRow(
+ label = "Use WiFi only",
+ value = syncSettings.wifiOnly,
+ onToggle = onWifiOnlyChanged
+ )
+}
+
+@Composable
+private fun SyncIntervalSelector(
+ intervalMinutes: Int,
+ onIntervalChanged: (Int) -> Unit,
+) {
+ val minInterval = 15
+ val maxInterval = 240
+ val stepMinutes = 5
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Sync interval",
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+
+ EInkActionButton(
+ text = "-",
+ onClick = { onIntervalChanged((intervalMinutes - stepMinutes).coerceAtLeast(minInterval)) },
+ enabled = intervalMinutes > minInterval,
+ isSecondary = true
+ )
+
+ Text(
+ text = "${intervalMinutes}m",
+ style = MaterialTheme.typography.body2,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+
+ EInkActionButton(
+ text = "+",
+ onClick = { onIntervalChanged((intervalMinutes + stepMinutes).coerceAtMost(maxInterval)) },
+ enabled = intervalMinutes < maxInterval,
+ isSecondary = true
+ )
+ }
+}
+
+@Composable
+fun ManualSyncButton(
+ syncSettings: SyncSettings, serverUrl: String, syncState: SyncState, onManualSync: () -> Unit
+) {
+ val label by remember(syncState) {
+ derivedStateOf {
+ when (syncState) {
+ is SyncState.Syncing -> "Syncing\u2026"
+ is SyncState.Success -> "Successfully Synced"
+ is SyncState.Error -> "Sync Failed"
+ else -> "Sync Now"
+ }
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (syncState is SyncState.Syncing) {
+ SyncProgressPanel(syncState)
+ }
+ Button(
+ onClick = onManualSync,
+ enabled = syncState is SyncState.Idle && syncSettings.syncEnabled && serverUrl.isNotEmpty(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ colors = eInkButtonColors(),
+ shape = EInkButtonShape
+ ) {
+ Text(label, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+private fun SyncProgressPanel(syncing: SyncState.Syncing) {
+ val overall = overallProgressOf(syncing).coerceIn(0f, 1f)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(1.dp, MaterialTheme.colors.onSurface)
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = syncing.currentStep.displayName(),
+ style = MaterialTheme.typography.body2,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+ Text(
+ text = "${(overall * 100).toInt()}%",
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(6.dp)
+ .border(1.dp, MaterialTheme.colors.onSurface)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(overall)
+ .height(6.dp)
+ .background(MaterialTheme.colors.onSurface)
+ )
+ }
+ syncing.item?.let { item ->
+ Text(
+ text = "Notebook ${item.index} of ${item.total} \u00b7 ${item.name}",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+ }
+}
+
+private fun SyncStep.displayName(): String = when (this) {
+ SyncStep.INITIALIZING -> "Preparing"
+ SyncStep.SYNCING_FOLDERS -> "Syncing folders"
+ SyncStep.APPLYING_DELETIONS -> "Applying deletions"
+ SyncStep.SYNCING_NOTEBOOKS -> "Syncing notebooks"
+ SyncStep.DOWNLOADING_NEW -> "Downloading new notebooks"
+ SyncStep.UPLOADING_DELETIONS -> "Uploading deletions"
+ SyncStep.FINALIZING -> "Finalizing"
+}
+
+private fun overallProgressOf(s: SyncState.Syncing): Float {
+ val start = s.stepProgress
+ val end = stepBandEnd(s.currentStep)
+ val frac = s.item?.let { it.index.toFloat() / it.total.coerceAtLeast(1) } ?: 0f
+ return start + (end - start) * frac
+}
+
+private fun stepBandEnd(step: SyncStep): Float = when (step) {
+ SyncStep.INITIALIZING -> 0.1f
+ SyncStep.SYNCING_FOLDERS -> 0.2f
+ SyncStep.APPLYING_DELETIONS -> 0.3f
+ SyncStep.SYNCING_NOTEBOOKS -> 0.6f
+ SyncStep.DOWNLOADING_NEW -> 0.8f
+ SyncStep.UPLOADING_DELETIONS -> 0.9f
+ SyncStep.FINALIZING -> 1.0f
+}
+
+@Composable
+fun ForceOperationsSection(
+ syncSettings: SyncSettings,
+ showForceUploadConfirm: Boolean,
+ showForceDownloadConfirm: Boolean,
+ onForceUploadRequested: (Boolean) -> Unit,
+ onForceDownloadRequested: (Boolean) -> Unit,
+ onConfirmForceUpload: () -> Unit,
+ onConfirmForceDownload: () -> Unit
+) {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ EInkActionButton(
+ text = "Upload All",
+ onClick = { onForceUploadRequested(true) },
+ enabled = syncSettings.syncEnabled,
+ modifier = Modifier.weight(1f),
+ fontSize = 12.sp
+ )
+ EInkActionButton(
+ text = "Download All",
+ onClick = { onForceDownloadRequested(true) },
+ enabled = syncSettings.syncEnabled,
+ modifier = Modifier.weight(1f),
+ fontSize = 12.sp
+ )
+ }
+
+ if (showForceUploadConfirm) {
+ ConfirmationDialog(
+ title = "Replace Server Data?",
+ message = "This will DELETE all notebooks on the server and replace them with your local data. This cannot be undone.",
+ onConfirm = onConfirmForceUpload,
+ onDismiss = { onForceUploadRequested(false) }
+ )
+ }
+ if (showForceDownloadConfirm) {
+ ConfirmationDialog(
+ title = "Replace Local Data?",
+ message = "This will DELETE all local notebooks and replace them with data from the server. This cannot be undone.",
+ onConfirm = onConfirmForceDownload,
+ onDismiss = { onForceDownloadRequested(false) }
+ )
+ }
+}
+
+@Composable
+fun SyncLogViewer(syncLogs: List, onClearLog: () -> Unit) {
+ val recentLogs = remember(syncLogs) { syncLogs.takeLast(30) }
+
+ Column {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .padding(8.dp)
+ ) {
+ if (recentLogs.isEmpty()) {
+ Text(
+ "No recent activity.",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
+ )
+ } else {
+ recentLogs.forEach { log ->
+ Text(
+ text = "[${log.timestamp}] ${log.message}",
+ style = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 10.sp,
+ color = MaterialTheme.colors.onSurface
+ ),
+ modifier = Modifier.padding(vertical = 1.dp)
+ )
+ }
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ EInkActionButton(
+ text = "Clear Log",
+ onClick = onClearLog,
+ modifier = Modifier.align(Alignment.End),
+ isSecondary = true,
+ fontSize = 10.sp
+ )
+ }
+}
+
+@Composable
+fun ConfirmationDialog(
+ title: String, message: String, onConfirm: () -> Unit, onDismiss: () -> Unit
+) {
+ androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
+ Surface(
+ color = MaterialTheme.colors.surface,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text(
+ title,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.h6,
+ color = MaterialTheme.colors.onSurface
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ message,
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ shape = EInkButtonShape,
+ colors = eInkButtonColors(isSecondary = true)
+ ) {
+ Text("Cancel")
+ }
+ Button(
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ shape = EInkButtonShape,
+ colors = eInkButtonColors()
+ ) {
+ Text("Confirm")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EInkActionButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isSecondary: Boolean = false,
+ isBold: Boolean = false,
+ fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier,
+ shape = EInkButtonShape,
+ colors = eInkButtonColors(isSecondary = isSecondary)
+ ) {
+ Text(
+ text = text,
+ fontWeight = if (isBold) FontWeight.Bold else null,
+ fontSize = fontSize
+ )
+ }
+}
+
+
+// ----------------------------------- //
+// -------- Previews ------- //
+// ----------------------------------- //
+
+
+@Preview(
+ showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode",
+// heightDp = 500
+)
+@Preview(
+ showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_NO,
+ name = "Light Mode",
+)
+@Composable
+fun SyncSettingsContentPreview() {
+ InkaTheme {
+ Surface(color = MaterialTheme.colors.background) {
+ Box(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ SyncSettings(
+ state = SyncSettingsUiState(
+ serverUrl = "https://webdav.example.com",
+ username = "demo",
+ password = "secret",
+ savedUsername = "demo",
+ savedPassword = "secret",
+ syncSettings = SyncSettings(
+ syncEnabled = true,
+ serverUrl = "https://webdav.example.com"
+ )
+ ), callbacks = SyncSettingsCallbacks()
+ )
+ }
+ }
+ }
+}
+
+
+@Preview(name = "Configured - Collapsed", showBackground = true)
+@Composable
+fun SyncSettingsConfiguredPreview() {
+ InkaTheme {
+ Surface(color = MaterialTheme.colors.background) {
+ SyncSettings(
+ state = SyncSettingsUiState(
+ serverUrl = "https://webdav.example.com/dav/",
+ username = "demo_user",
+ isPasswordSaved = true,
+ syncSettings = SyncSettings(
+ syncEnabled = true,
+ lastSyncTime = "2024-03-20 14:30:05"
+ )
+ ),
+ callbacks = SyncSettingsCallbacks()
+ )
+ }
+ }
+}
+
+@Preview(name = "Configured - Syncing", showBackground = true)
+@Composable
+fun SyncSettingsSyncingPreview() {
+ InkaTheme {
+ Surface(color = MaterialTheme.colors.background) {
+ SyncSettings(
+ state = SyncSettingsUiState(
+ serverUrl = "https://webdav.example.com/dav/",
+ username = "demo_user",
+ isPasswordSaved = true,
+ syncState = SyncState.Syncing(
+ SyncStep.SYNCING_NOTEBOOKS,
+ 0.45f,
+ "Syncing notebooks..."
+ ),
+ syncSettings = SyncSettings(
+ syncEnabled = true,
+ lastSyncTime = "2024-03-20 14:30:05"
+ )
+ ),
+ callbacks = SyncSettingsCallbacks()
+ )
+ }
+ }
+}
+
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 12861f57..4e6b9234 100644
--- a/app/src/main/java/com/ethran/notable/utils/versionChecker.kt
+++ b/app/src/main/java/com/ethran/notable/utils/versionChecker.kt
@@ -140,7 +140,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..0fc19a48 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -73,7 +73,7 @@
Zalecane jest używanie trybu HD lub Regal
Zastosowano (%1$s)
Ustaw tryb HD (obecnie: %1$s)
- Przyznano ✓
+ Przyznano
Przyznaj uprawnienia
Kontynuuj
Najpierw zakończ konfigurację
@@ -89,7 +89,7 @@
• Przeciągnij w górę/w dół: Przewijaj stronę\n• Przeciągnij w lewo/w prawo: Poprzednia/następna strona\n• Uszczypnij dwoma palcami: Przybliż/oddal\n• Stuknij numer strony: Szybkie przejście\n• Przeciągnięcie dwoma palcami w lewo/w prawo: Pokaż/ukryj pasek narzędzi
-
+
Wskazówki
• Użyj opcji eksportowania zanacznia, aby szybko udostępniać obrazy.\n• Stuknij numer strony, aby szybko przejść do wybranej strony.\n• Spróbuj włączyć \'Zamaż aby usnąć\'(\'Scribble to Erase\') w Ustawieniach, aby naturalnie wymazywać.\n• Podwójne stuknięcie cofa, a podwójne stuknięcie na zaznaczeniu kopiuje je.\n• Możesz używać Notable jako podglądu PDF w czasie rzeczywistym dla LaTeX — zobacz README.\n• Możesz dostosować akcje gestów w Ustawieniach.
@@ -121,4 +121,70 @@
Zamaż\n→usuń
Pozycja paska narzędzi
-
\ No newline at end of file
+
+
+ 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
+ Rozpoczynam zaplanowaną synchronizację...
+ Zaplanowana synchronizacja zakończona
+
+
+ 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
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8d691ce3..f1d42663 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!
@@ -64,7 +65,7 @@
It\'s recommended to use HD mode or Regal mode
Applied (%1$s)
Set HD Mode (currently in %1$s)
- Granted ✓
+ Granted
Grant Permission
Continue
Complete Setup First
@@ -104,4 +105,76 @@
Scribble\nto Erase
Toolbar Position
-
\ No newline at end of file
+
+ 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
+ Starting scheduled sync...
+ Scheduled sync completed
+
+
+ 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
+
+
diff --git a/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt b/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt
new file mode 100644
index 00000000..22efd787
--- /dev/null
+++ b/app/src/test/java/com/ethran/notable/sync/SyncOrchestratorTest.kt
@@ -0,0 +1,32 @@
+package com.ethran.notable.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SyncOrchestratorTest {
+
+ private fun networkFailure(): SyncResult = SyncResult.Failure(SyncError.NETWORK_ERROR)
+
+ @Test
+ fun syncResult_failure_keeps_error_value() {
+ when (val result = networkFailure()) {
+ is SyncResult.Failure -> assertEquals(SyncError.NETWORK_ERROR, result.error)
+ SyncResult.Success -> error("Expected failure")
+ }
+ }
+
+ @Test
+ fun syncSummary_holds_counters_and_duration() {
+ val summary = SyncSummary(
+ notebooksSynced = 3,
+ notebooksDownloaded = 2,
+ notebooksDeleted = 1,
+ duration = 1500L
+ )
+
+ assertEquals(3, summary.notebooksSynced)
+ assertEquals(2, summary.notebooksDownloaded)
+ assertEquals(1, summary.notebooksDeleted)
+ assertEquals(1500L, summary.duration)
+ }
+}
diff --git a/app/src/test/java/com/ethran/notable/sync/SyncPathsTest.kt b/app/src/test/java/com/ethran/notable/sync/SyncPathsTest.kt
new file mode 100644
index 00000000..dcdd5387
--- /dev/null
+++ b/app/src/test/java/com/ethran/notable/sync/SyncPathsTest.kt
@@ -0,0 +1,28 @@
+package com.ethran.notable.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SyncPathsTest {
+
+ @Test
+ fun root_and_core_paths_are_stable() {
+ assertEquals("/notable", SyncPaths.rootDir())
+ assertEquals("/notable/notebooks", SyncPaths.notebooksDir())
+ assertEquals("/notable/deletions", SyncPaths.tombstonesDir())
+ assertEquals("/notable/folders.json", SyncPaths.foldersFile())
+ }
+
+ @Test
+ fun notebook_scoped_paths_are_composed_correctly() {
+ val notebookId = "nb-1"
+ val pageId = "page-1"
+
+ assertEquals("/notable/notebooks/nb-1", SyncPaths.notebookDir(notebookId))
+ assertEquals("/notable/notebooks/nb-1/manifest.json", SyncPaths.manifestFile(notebookId))
+ assertEquals("/notable/notebooks/nb-1/pages", SyncPaths.pagesDir(notebookId))
+ assertEquals("/notable/notebooks/nb-1/pages/page-1.json", SyncPaths.pageFile(notebookId, pageId))
+ assertEquals("/notable/deletions/nb-1", SyncPaths.tombstone(notebookId))
+ }
+}
+
diff --git a/app/src/test/java/com/ethran/notable/sync/SyncPortsTest.kt b/app/src/test/java/com/ethran/notable/sync/SyncPortsTest.kt
new file mode 100644
index 00000000..5dd41194
--- /dev/null
+++ b/app/src/test/java/com/ethran/notable/sync/SyncPortsTest.kt
@@ -0,0 +1,21 @@
+package com.ethran.notable.sync
+
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class SyncPortsTest {
+
+ @Test
+ fun webDavClientFactoryAdapter_creates_client_instance() {
+ val factory = WebDavClientFactoryAdapter()
+
+ val client = factory.create(
+ serverUrl = "https://example.com",
+ username = "user",
+ password = "pass"
+ )
+
+ assertNotNull(client)
+ }
+}
+
diff --git a/app/src/test/java/com/ethran/notable/sync/SyncProgressReporterTest.kt b/app/src/test/java/com/ethran/notable/sync/SyncProgressReporterTest.kt
new file mode 100644
index 00000000..34bef94c
--- /dev/null
+++ b/app/src/test/java/com/ethran/notable/sync/SyncProgressReporterTest.kt
@@ -0,0 +1,140 @@
+package com.ethran.notable.sync
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SyncProgressReporterTest {
+
+ private fun newReporter(): SyncProgressReporter =
+ SyncProgressReporterImpl(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined))
+
+ @Test
+ fun initial_state_is_idle() {
+ val reporter = newReporter()
+ assertEquals(SyncState.Idle, reporter.state.value)
+ }
+
+ @Test
+ fun beginStep_emits_syncing_with_step_details_and_null_item() {
+ val reporter = newReporter()
+
+ reporter.beginStep(SyncStep.SYNCING_FOLDERS, 0.1f, "Syncing folders...")
+
+ val s = reporter.state.value
+ assertTrue("Expected Syncing, got $s", s is SyncState.Syncing)
+ s as SyncState.Syncing
+ assertEquals(SyncStep.SYNCING_FOLDERS, s.currentStep)
+ assertEquals(0.1f, s.stepProgress, 0.0001f)
+ assertEquals("Syncing folders...", s.details)
+ assertNull(s.item)
+ }
+
+ @Test
+ fun beginItem_populates_item_progress_within_current_step() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "Syncing local notebooks...")
+
+ reporter.beginItem(1, 3, "Meeting Notes")
+
+ val s = reporter.state.value as SyncState.Syncing
+ assertEquals(SyncStep.SYNCING_NOTEBOOKS, s.currentStep)
+ assertNotNull(s.item)
+ val item = s.item!!
+ assertEquals(1, item.index)
+ assertEquals(3, item.total)
+ assertEquals("Meeting Notes", item.name)
+ }
+
+ @Test
+ fun endItem_clears_item_progress_but_keeps_step() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "Syncing...")
+ reporter.beginItem(2, 5, "Diary")
+
+ reporter.endItem()
+
+ val s = reporter.state.value as SyncState.Syncing
+ assertEquals(SyncStep.SYNCING_NOTEBOOKS, s.currentStep)
+ assertNull(s.item)
+ }
+
+ @Test
+ fun beginItem_called_twice_replaces_item_not_accumulates() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "Syncing...")
+
+ reporter.beginItem(1, 3, "First")
+ reporter.beginItem(2, 3, "Second")
+
+ val item = (reporter.state.value as SyncState.Syncing).item!!
+ assertEquals(2, item.index)
+ assertEquals("Second", item.name)
+ }
+
+ @Test
+ fun beginStep_clears_stale_item_from_previous_step() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "...")
+ reporter.beginItem(1, 1, "leftover")
+
+ reporter.beginStep(SyncStep.FINALIZING, 0.9f, "Finalizing...")
+
+ val s = reporter.state.value as SyncState.Syncing
+ assertEquals(SyncStep.FINALIZING, s.currentStep)
+ assertNull("beginStep must clear stale item from previous step", s.item)
+ }
+
+ @Test
+ fun finishSuccess_emits_success_state_with_summary() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "...")
+ val summary = SyncSummary(notebooksSynced = 2, notebooksDownloaded = 1, notebooksDeleted = 0, duration = 500L)
+
+ reporter.finishSuccess(summary)
+
+ val s = reporter.state.value
+ assertTrue(s is SyncState.Success)
+ assertEquals(summary, (s as SyncState.Success).summary)
+ }
+
+ @Test
+ fun finishError_emits_error_state_preserving_current_step() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "...")
+
+ reporter.finishError(SyncError.NETWORK_ERROR, canRetry = true)
+
+ val s = reporter.state.value as SyncState.Error
+ assertEquals(SyncError.NETWORK_ERROR, s.error)
+ assertEquals(SyncStep.SYNCING_NOTEBOOKS, s.step)
+ assertTrue(s.canRetry)
+ }
+
+ @Test
+ fun finishError_when_not_syncing_uses_initializing_step() {
+ val reporter = newReporter()
+
+ reporter.finishError(SyncError.AUTH_ERROR, canRetry = false)
+
+ val s = reporter.state.value as SyncState.Error
+ assertEquals(SyncError.AUTH_ERROR, s.error)
+ assertEquals(SyncStep.INITIALIZING, s.step)
+ }
+
+ @Test
+ fun reset_returns_state_to_idle() {
+ val reporter = newReporter()
+ reporter.beginStep(SyncStep.SYNCING_NOTEBOOKS, 0.3f, "...")
+ reporter.beginItem(1, 1, "X")
+
+ reporter.reset()
+
+ assertEquals(SyncState.Idle, reporter.state.value)
+ }
+}
diff --git a/app/src/test/java/com/ethran/notable/sync/serializers/FolderSerializerTest.kt b/app/src/test/java/com/ethran/notable/sync/serializers/FolderSerializerTest.kt
new file mode 100644
index 00000000..d7835f32
--- /dev/null
+++ b/app/src/test/java/com/ethran/notable/sync/serializers/FolderSerializerTest.kt
@@ -0,0 +1,81 @@
+package com.ethran.notable.sync.serializers
+
+import com.ethran.notable.data.db.Folder
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import java.util.Date
+
+class FolderSerializerTest {
+
+ @Test
+ fun serialize_and_deserialize_preserves_folder_fields() {
+ val createdAt = Date(1_700_000_000_000)
+ val updatedAt = Date(1_700_000_123_000)
+ val folders = listOf(
+ Folder(
+ id = "f-root",
+ title = "Root",
+ parentFolderId = null,
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ ),
+ Folder(
+ id = "f-child",
+ title = "Child",
+ parentFolderId = "f-root",
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ )
+ )
+
+ val json = FolderSerializer.serializeFolders(folders)
+ val restored = FolderSerializer.deserializeFolders(json)
+
+ assertEquals(2, restored.size)
+ assertEquals("f-root", restored[0].id)
+ assertEquals("Root", restored[0].title)
+ assertEquals(null, restored[0].parentFolderId)
+
+ assertEquals("f-child", restored[1].id)
+ assertEquals("Child", restored[1].title)
+ assertEquals("f-root", restored[1].parentFolderId)
+ }
+
+ @Test
+ fun deserialize_ignores_unknown_fields_in_json() {
+ val json = """
+ {
+ "version": 1,
+ "folders": [
+ {
+ "id": "f-1",
+ "title": "Folder 1",
+ "parentFolderId": null,
+ "createdAt": "2024-01-01T10:00:00Z",
+ "updatedAt": "2024-01-01T10:00:00Z",
+ "extra": "ignored"
+ }
+ ],
+ "serverTimestamp": "2024-01-01T10:00:00Z",
+ "rootExtra": "ignored"
+ }
+ """.trimIndent()
+
+ val restored = FolderSerializer.deserializeFolders(json)
+
+ assertEquals(1, restored.size)
+ assertEquals("f-1", restored.first().id)
+ assertEquals("Folder 1", restored.first().title)
+ }
+
+ @Test
+ fun getServerTimestamp_returns_value_for_valid_json() {
+ val json = FolderSerializer.serializeFolders(emptyList())
+
+ val timestamp = FolderSerializer.getServerTimestamp(json)
+
+ assertNotNull(timestamp)
+ }
+}
+
diff --git a/docs/webdav-sync-technical.md b/docs/webdav-sync-technical.md
new file mode 100644
index 00000000..3b3859bd
--- /dev/null
+++ b/docs/webdav-sync-technical.md
@@ -0,0 +1,601 @@
+# 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)
+
+---
+
+## 1) Architecture Overview
+
+The current sync architecture is service-oriented: `SyncOrchestrator` coordinates the flow,
+while focused services handle preflight checks, folder sync, notebook reconciliation/transfer,
+and force operations. WebDAV client creation is abstracted behind
+`WebDavClientFactoryPort` (`SyncPorts.kt`) to reduce direct infrastructure coupling.
+
+---
+
+## 2) Component Overview
+
+All sync code lives in `com.ethran.notable.sync`. The components and their responsibilities:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ SyncOrchestrator │
+│ Orchestrates full sync flow, holds syncMutex. │
+├─────────────────────────────────────────────────────────────┤
+│ SyncPreflightService FolderSyncService │
+│ NotebookReconciliationService NotebookSyncService │
+│ SyncForceService SyncProgressReporter (state) │
+├─────────────────────────────────────────────────────────────┤
+│ WebDavClientFactoryPort -> WebDavClientFactoryAdapter │
+│ WebDAVClient (OkHttp, PROPFIND/XML) │
+└─────────────────────────────────────────────────────────────┘
+```
+
+| File | Role |
+|---------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [`SyncOrchestrator.kt`](../app/src/main/java/com/ethran/notable/sync/SyncOrchestrator.kt) | Core orchestrator. Full sync flow, per-notebook trigger, deletion upload. Holds the shared `syncMutex` (companion object) for process-wide concurrency control. Delegates progress/state reporting to `SyncProgressReporter`. |
+| [`SyncPreflightService.kt`](../app/src/main/java/com/ethran/notable/sync/SyncPreflightService.kt) | Pre-sync checks and server directory bootstrap (`/notable`, `/notebooks`, `/deletions`). |
+| [`FolderSyncService.kt`](../app/src/main/java/com/ethran/notable/sync/FolderSyncService.kt) | Folder hierarchy sync (folders.json merge + upsert). |
+| [`NotebookReconciliationService.kt`](../app/src/main/java/com/ethran/notable/sync/NotebookReconciliationService.kt) | Per-notebook conflict decision (upload/download/no-op) based on manifest timestamps. Reports per-item progress via `SyncProgressReporter.beginItem`/`endItem`. |
+| [`NotebookSyncService.kt`](../app/src/main/java/com/ethran/notable/sync/NotebookSyncService.kt) | Per-notebook upload/download execution. Reports per-item progress via `SyncProgressReporter.beginItem`/`endItem` when downloading new notebooks. |
+| [`SyncProgressReporter.kt`](../app/src/main/java/com/ethran/notable/sync/SyncProgressReporter.kt) | `@Singleton` owner of the `SyncState` `StateFlow`. Interface + `SyncProgressReporterImpl` + Hilt `@Binds` module + `SyncProgressReporterEntryPoint`. Write-side API: `beginStep`, `beginItem`, `endItem`, `finishSuccess`, `finishError`, `reset`. Read-side: `state`. Consumers inject `SyncProgressReporter` rather than touching `SyncOrchestrator` for state. |
+| [`SyncForceService.kt`](../app/src/main/java/com/ethran/notable/sync/SyncForceService.kt) | Force upload/download flows (full side replacement) used by settings actions. |
+| [`SyncPorts.kt`](../app/src/main/java/com/ethran/notable/sync/SyncPorts.kt) | DI port/adapter for WebDAV client creation (`WebDavClientFactoryPort`). |
+| [`WebDAVClient.kt`](../app/src/main/java/com/ethran/notable/sync/WebDAVClient.kt) | HTTP/WebDAV operations. PROPFIND XML parsing. Connection testing. Streaming downloads. ETag-aware downloads and `If-Match` guarded uploads for optimistic concurrency. |
+| [`NotebookSerializer.kt`](../app/src/main/java/com/ethran/notable/sync/serializers/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/serializers/FolderSerializer.kt) | Serializes/deserializes the folder hierarchy to/from `folders.json`. |
+| [`SyncWorker.kt`](../app/src/main/java/com/ethran/notable/sync/SyncWorker.kt) | `CoroutineWorker` for WorkManager integration. Checks connectivity and credentials before delegating to `SyncOrchestrator`. |
+| [`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/, /notable/notebooks/, /notable/deletions/ exist on server (MKCOL)
+
+2. SYNC FOLDERS
+ ├── GET /notable/folders.json (if exists) + capture ETag
+ ├── Merge: for each folder, keep the version with the later updatedAt
+ ├── Upsert merged folders into local Room database
+ └── PUT /notable/folders.json with If-Match (captured ETag)
+
+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 + capture ETag, parse updatedAt
+ │ ├── Compare timestamps (with ±1s tolerance):
+ │ │ ├── Local newer → upload notebook manifest with If-Match (captured ETag)
+ │ │ ├── Remote newer → download notebook
+ │ │ └── Within tolerance → skip
+ │ ├── If server changed between GET and PUT, server returns 412 and sync reports CONFLICT
+ │ └── (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.
+
+Current implementation does not include a `deletions.json` migration path; tombstones are the only
+supported deletion propagation mechanism.
+
+### 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).
+- **Concurrent updates can return conflict (`412 Precondition Failed`).** `folders.json` and
+ `manifest.json` updates are protected by `If-Match`. This prevents silent overwrite, but can abort
+ a sync step with `CONFLICT` when another device changes the resource between GET and PUT.
+- **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)
+ CONFLICT, // ETag precondition failed (HTTP 412)
+ UNKNOWN_ERROR // Catch-all for unexpected exceptions
+}
+```
+
+### 7.2 Concurrency Control
+
+A companion-object-level `Mutex` in `SyncOrchestrator` prevents concurrent sync operations on a
+single device. If a sync is already running, `syncAllNotebooks()`, `forceUploadAll()`, and
+`forceDownloadAll()` return `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.
+- **Non-retryable sync errors** (`AUTH_ERROR`, `CONFIG_ERROR`, `CLOCK_SKEW`, `WIFI_REQUIRED`, `CONFLICT`): Return
+ `Result.success()` to avoid useless retry loops.
+- **Other/unknown 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, stepProgress, details, item?) → Success(summary) → Idle
+ → Error(error, step, canRetry)
+```
+
+- `Syncing` includes a `SyncStep` enum, a float `stepProgress` (0.0–1.0) for the current step, a `details` string, and an optional `item: ItemProgress?` (`index`, `total`, `name`) set by services that loop over notebooks (`NotebookReconciliationService`, `NotebookSyncService`).
+- `SyncState` is owned by `SyncProgressReporter` (Hilt `@Singleton`). `SyncSettingsTab` renders it via `SyncProgressPanel`, using helpers `SyncStep.displayName()`, `overallProgressOf(Syncing)`, and `stepBandEnd(SyncStep)` to map per-step progress onto an overall bar.
+- `Success` auto-resets to `Idle` after 3 seconds.
+- `Error` persists until the next sync attempt.
+
+---
+
+## 8) Integration Points
+
+### 8.1 Dependencies
+
+| Dependency | Purpose |
+|----------------------------------------------------|---------------------------------------|
+| `com.squareup.okhttp3:okhttp` | HTTP client for all WebDAV operations |
+| `androidx.security:security-crypto` | Encrypted credential storage |
+| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON serialization/deserialization |
+| `androidx.work:work-runtime-ktx` | Background sync scheduling |
+
+---
+
+## 9) Future Work
+
+Potential enhancements beyond the current implementation, roughly ordered by impact:
+
+1. **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.
+2. **Conflict recovery strategy.** On `CONFLICT` (412), add an automatic re-GET/reconcile/retry path
+ for selected operations instead of finishing current run as skipped.
+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.
+
+---
+
+**Version**: 1.5
+**Last Updated**: 2026-04-18
diff --git a/docs/webdav-sync-user.md b/docs/webdav-sync-user.md
new file mode 100644
index 00000000..ee08fb22
--- /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