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