diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index dfa201d..356b9fe 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,18 +5,18 @@
-
+
-
+
-
+
-
+
@@ -44,12 +44,6 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
deleted file mode 100644
index a60c4bf..0000000
--- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
+++ /dev/null
@@ -1,854 +0,0 @@
-@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
-
-package dev.dettmer.simplenotes
-
-import android.Manifest
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Bundle
-import dev.dettmer.simplenotes.utils.Logger
-import android.view.Menu
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.localbroadcastmanager.content.LocalBroadcastManager
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.color.DynamicColors
-import com.google.android.material.floatingactionbutton.FloatingActionButton
-import com.google.android.material.snackbar.Snackbar
-import com.google.android.material.card.MaterialCardView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.dettmer.simplenotes.adapters.NotesAdapter
-import dev.dettmer.simplenotes.models.Note
-import dev.dettmer.simplenotes.storage.NotesStorage
-import dev.dettmer.simplenotes.sync.SyncWorker
-import dev.dettmer.simplenotes.utils.NotificationHelper
-import dev.dettmer.simplenotes.utils.showToast
-import dev.dettmer.simplenotes.utils.Constants
-import android.widget.TextView
-import android.widget.CheckBox
-import android.widget.Toast
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import dev.dettmer.simplenotes.sync.WebDavSyncService
-import dev.dettmer.simplenotes.sync.SyncStateManager
-import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import android.view.View
-import android.widget.LinearLayout
-import android.view.Gravity
-import android.widget.PopupMenu
-import dev.dettmer.simplenotes.models.NoteType
-
-/**
- * Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
- * Ersetzt durch ComposeMainActivity
- */
-@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
-class MainActivity : AppCompatActivity() {
-
- private lateinit var recyclerViewNotes: RecyclerView
- private lateinit var emptyStateCard: MaterialCardView
- private lateinit var fabAddNote: FloatingActionButton
- private lateinit var toolbar: MaterialToolbar
- private lateinit var swipeRefreshLayout: SwipeRefreshLayout
-
- // π v1.3.1: Sync Status Banner
- private lateinit var syncStatusBanner: LinearLayout
- private lateinit var syncStatusText: TextView
-
- private lateinit var adapter: NotesAdapter
- private val storage by lazy { NotesStorage(this) }
-
- // Menu reference for sync button state
- private var optionsMenu: Menu? = null
-
- // Track pending deletions to prevent flicker when notes reload
- private val pendingDeletions = mutableSetOf()
-
- private val prefs by lazy {
- getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
- }
-
- companion object {
- private const val TAG = "MainActivity"
- private const val REQUEST_NOTIFICATION_PERMISSION = 1001
- private const val REQUEST_SETTINGS = 1002
- private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
- private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
- private const val SYNC_COMPLETED_DELAY_MS = 1500L
- private const val ERROR_DISPLAY_DELAY_MS = 3000L
- }
-
- /**
- * BroadcastReceiver fΓΌr Background-Sync Completion (Periodic Sync)
- */
- private val syncCompletedReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- val success = intent?.getBooleanExtra("success", false) ?: false
- val count = intent?.getIntExtra("count", 0) ?: 0
-
- Logger.d(TAG, "π‘ Sync completed broadcast received: success=$success, count=$count")
-
- // UI refresh
- if (success && count > 0) {
- loadNotes()
- Logger.d(TAG, "π Notes reloaded after background sync")
- }
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- // Install Splash Screen (Android 12+)
- installSplashScreen()
-
- super.onCreate(savedInstanceState)
-
- // Apply Dynamic Colors for Android 12+ (Material You)
- DynamicColors.applyToActivityIfAvailable(this)
-
- setContentView(R.layout.activity_main)
-
- // Logger initialisieren und File-Logging aktivieren wenn eingestellt
- Logger.init(this)
- if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
- Logger.setFileLoggingEnabled(true)
- }
-
- // Alte Sync-Notifications beim App-Start lΓΆschen
- NotificationHelper.clearSyncNotifications(this)
-
- // Permission fΓΌr Notifications (Android 13+)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- requestNotificationPermission()
- }
-
- // π v1.7.2: Debug Locale fΓΌr Fehlersuche
- logLocaleInfo()
-
- findViews()
- setupToolbar()
- setupRecyclerView()
- setupFab()
-
- // v1.4.1: Migrate checklists for backwards compatibility
- migrateChecklistsForBackwardsCompat()
-
- loadNotes()
-
- // π v1.3.1: Observe sync state for UI updates
- setupSyncStateObserver()
- }
-
- /**
- * π v1.3.1: Beobachtet Sync-Status fΓΌr UI-Feedback
- */
- private fun setupSyncStateObserver() {
- SyncStateManager.syncStatus.observe(this) { status ->
- when (status.state) {
- SyncStateManager.SyncState.SYNCING -> {
- // Disable sync controls
- setSyncControlsEnabled(false)
- // π v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
- syncStatusText.text = getString(R.string.sync_status_syncing)
- syncStatusBanner.visibility = View.VISIBLE
- }
- SyncStateManager.SyncState.COMPLETED -> {
- // Re-enable sync controls
- setSyncControlsEnabled(true)
- swipeRefreshLayout.isRefreshing = false
- // Show completed briefly, then hide
- syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
- lifecycleScope.launch {
- kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
- syncStatusBanner.visibility = View.GONE
- SyncStateManager.reset()
- }
- }
- SyncStateManager.SyncState.ERROR -> {
- // Re-enable sync controls
- setSyncControlsEnabled(true)
- swipeRefreshLayout.isRefreshing = false
- // Show error briefly, then hide
- syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
- lifecycleScope.launch {
- kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
- syncStatusBanner.visibility = View.GONE
- SyncStateManager.reset()
- }
- }
- SyncStateManager.SyncState.IDLE -> {
- setSyncControlsEnabled(true)
- swipeRefreshLayout.isRefreshing = false
- syncStatusBanner.visibility = View.GONE
- }
- // v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
- SyncStateManager.SyncState.SYNCING_SILENT -> {
- setSyncControlsEnabled(false)
- // Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
- }
- }
- }
- }
-
- /**
- * π v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
- */
- private fun setSyncControlsEnabled(enabled: Boolean) {
- // Menu Sync-Button
- optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
- // SwipeRefresh
- swipeRefreshLayout.isEnabled = enabled
- }
-
- override fun onResume() {
- super.onResume()
-
- Logger.d(TAG, "π± MainActivity.onResume() - Registering receivers")
-
- // Register BroadcastReceiver fΓΌr Background-Sync
- LocalBroadcastManager.getInstance(this).registerReceiver(
- syncCompletedReceiver,
- IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
- )
-
- Logger.d(TAG, "π‘ BroadcastReceiver registered (sync-completed)")
-
- // Reload notes (scroll to top wird in loadNotes() gemacht)
- loadNotes()
-
- // Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
- triggerAutoSync("onResume")
- }
-
- /**
- * Automatischer Sync (onResume)
- * - Nutzt WiFi-gebundenen Socket (VPN Fix!)
- * - Nur Success-Toast (kein "Auto-Sync..." Toast)
- *
- * NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
- * v1.5.0: Silent-Sync - kein Banner wΓ€hrend des Syncs, Fehler werden trotzdem angezeigt
- */
- private fun triggerAutoSync(source: String = "unknown") {
- // Throttling: Max 1 Sync pro Minute
- if (!canTriggerAutoSync()) {
- return
- }
-
- // π v1.3.1: Check if sync already running
- // v1.5.0: silent=true - kein Banner bei Auto-Sync
- if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
- Logger.d(TAG, "βοΈ Auto-sync ($source): Another sync already in progress")
- return
- }
-
- Logger.d(TAG, "π Auto-sync triggered ($source)")
-
- // Update last sync timestamp
- prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
-
- lifecycleScope.launch {
- try {
- val syncService = WebDavSyncService(this@MainActivity)
-
- // π₯ v1.1.2: Check if there are unsynced changes first (performance optimization)
- if (!syncService.hasUnsyncedChanges()) {
- Logger.d(TAG, "βοΈ Auto-sync ($source): No unsynced changes - skipping")
- SyncStateManager.reset()
- return@launch
- }
-
- // β WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
- val isReachable = withContext(Dispatchers.IO) {
- syncService.isServerReachable()
- }
-
- if (!isReachable) {
- Logger.d(TAG, "βοΈ Auto-sync ($source): Server not reachable - skipping silently")
- SyncStateManager.reset()
- return@launch
- }
-
- // Server ist erreichbar β Sync durchfΓΌhren
- val result = withContext(Dispatchers.IO) {
- syncService.syncNotes()
- }
-
- // Feedback abhΓ€ngig von Source
- if (result.isSuccess && result.syncedCount > 0) {
- Logger.d(TAG, "β
Auto-sync successful ($source): ${result.syncedCount} notes")
- SyncStateManager.markCompleted("${result.syncedCount} Notizen")
-
- // onResume: Nur Success-Toast
- showToast("β
Gesynct: ${result.syncedCount} Notizen")
- loadNotes()
-
- } else if (result.isSuccess) {
- Logger.d(TAG, "βΉοΈ Auto-sync ($source): No changes")
- SyncStateManager.markCompleted()
-
- } else {
- Logger.e(TAG, "β Auto-sync failed ($source): ${result.errorMessage}")
- SyncStateManager.markError(result.errorMessage)
- // Kein Toast - App ist im Hintergrund
- }
-
- } catch (e: Exception) {
- Logger.e(TAG, "π₯ Auto-sync exception ($source): ${e.message}")
- SyncStateManager.markError(e.message)
- // Kein Toast - App ist im Hintergrund
- }
- }
- }
-
- /**
- * PrΓΌft ob Auto-Sync getriggert werden darf (Throttling)
- */
- private fun canTriggerAutoSync(): Boolean {
- val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
- val now = System.currentTimeMillis()
- val timeSinceLastSync = now - lastSyncTime
-
- if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
- val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
- Logger.d(TAG, "β³ Auto-sync throttled - wait ${remainingSeconds}s")
- return false
- }
-
- return true
- }
-
- override fun onPause() {
- super.onPause()
-
- // Unregister BroadcastReceiver
- LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
- Logger.d(TAG, "π‘ BroadcastReceiver unregistered")
- }
-
- private fun findViews() {
- recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
- emptyStateCard = findViewById(R.id.emptyStateCard)
- fabAddNote = findViewById(R.id.fabAddNote)
- toolbar = findViewById(R.id.toolbar)
- swipeRefreshLayout = findViewById(R.id.swipeRefreshLayout)
-
- // π v1.3.1: Sync Status Banner
- syncStatusBanner = findViewById(R.id.syncStatusBanner)
- syncStatusText = findViewById(R.id.syncStatusText)
- }
-
- private fun setupToolbar() {
- setSupportActionBar(toolbar)
- }
-
- private fun setupRecyclerView() {
- adapter = NotesAdapter { note ->
- openNoteEditor(note.id)
- }
- recyclerViewNotes.adapter = adapter
- recyclerViewNotes.layoutManager = LinearLayoutManager(this)
-
- // π₯ v1.1.2: Setup Pull-to-Refresh
- setupPullToRefresh()
-
- // Setup Swipe-to-Delete
- setupSwipeToDelete()
- }
-
- /**
- * Setup Pull-to-Refresh fΓΌr manuellen Sync (v1.1.2)
- */
- private fun setupPullToRefresh() {
- swipeRefreshLayout.setOnRefreshListener {
- Logger.d(TAG, "π Pull-to-Refresh triggered - starting manual sync")
-
- // π v1.3.1: Check if sync already running (Banner zeigt Status)
- if (!SyncStateManager.tryStartSync("pullToRefresh")) {
- swipeRefreshLayout.isRefreshing = false
- return@setOnRefreshListener
- }
-
- lifecycleScope.launch {
- try {
- val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
- val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
-
- if (serverUrl.isNullOrEmpty()) {
- showToast("β οΈ Server noch nicht konfiguriert")
- SyncStateManager.reset()
- return@launch
- }
-
- val syncService = WebDavSyncService(this@MainActivity)
-
- // π₯ v1.1.2: Check if there are unsynced changes first (performance optimization)
- if (!syncService.hasUnsyncedChanges()) {
- Logger.d(TAG, "βοΈ No unsynced changes, skipping server reachability check")
- SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
- return@launch
- }
-
- // Check if server is reachable
- if (!syncService.isServerReachable()) {
- SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
- return@launch
- }
-
- // Perform sync
- val result = syncService.syncNotes()
-
- if (result.isSuccess) {
- SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
- loadNotes()
- } else {
- SyncStateManager.markError(result.errorMessage)
- }
- } catch (e: Exception) {
- Logger.e(TAG, "Pull-to-Refresh sync failed", e)
- SyncStateManager.markError(e.message)
- }
- }
- }
-
- // Set Material 3 color scheme
- swipeRefreshLayout.setColorSchemeResources(
- com.google.android.material.R.color.material_dynamic_primary50
- )
- }
-
- private fun setupSwipeToDelete() {
- val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
- 0, // No drag
- ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
- ) {
- override fun onMove(
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder
- ): Boolean = false
-
- override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- val position = viewHolder.bindingAdapterPosition
- val swipedNote = adapter.currentList[position]
-
- // Store original list BEFORE removing note
- val originalList = adapter.currentList.toList()
-
- // Remove from list for visual feedback (NOT from storage yet!)
- val listWithoutNote = originalList.toMutableList().apply {
- removeAt(position)
- }
- adapter.submitList(listWithoutNote)
-
- // Show dialog with ability to restore
- showServerDeletionDialog(swipedNote, originalList)
- }
-
- override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
- // Require 80% swipe to trigger
- return 0.8f
- }
- })
-
- itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
- }
-
- private fun showServerDeletionDialog(note: Note, originalList: List) {
- val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
-
- if (alwaysDeleteFromServer) {
- // Auto-delete from server without asking
- deleteNoteLocally(note, deleteFromServer = true)
- return
- }
-
- val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
- val checkboxAlways = dialogView.findViewById(R.id.checkboxAlwaysDeleteFromServer)
-
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.legacy_delete_dialog_title))
- .setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
- .setView(dialogView)
- .setNeutralButton(getString(R.string.cancel)) { _, _ ->
- // RESTORE: Re-submit original list (note is NOT deleted from storage)
- adapter.submitList(originalList)
- }
- .setOnCancelListener {
- // User pressed back - also restore
- adapter.submitList(originalList)
- }
- .setPositiveButton("Nur lokal") { _, _ ->
- if (checkboxAlways.isChecked) {
- prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
- }
- // NOW actually delete from storage
- deleteNoteLocally(note, deleteFromServer = false)
- }
- .setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
- if (checkboxAlways.isChecked) {
- prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
- }
- deleteNoteLocally(note, deleteFromServer = true)
- }
- .setCancelable(true)
- .show()
- }
-
- private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
- // Track pending deletion to prevent flicker
- pendingDeletions.add(note.id)
-
- // Delete from storage
- storage.deleteNote(note.id)
-
- // Reload to reflect changes
- loadNotes()
-
- // Show Snackbar with UNDO option
- val message = if (deleteFromServer) {
- getString(R.string.legacy_delete_with_server, note.title)
- } else {
- getString(R.string.legacy_delete_local_only, note.title)
- }
-
- Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
- .setAction(getString(R.string.snackbar_undo)) {
- // UNDO: Restore note
- storage.saveNote(note)
- pendingDeletions.remove(note.id)
- loadNotes()
- }
- .addCallback(object : Snackbar.Callback() {
- override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
- if (event != DISMISS_EVENT_ACTION) {
- // Snackbar dismissed without UNDO
- pendingDeletions.remove(note.id)
-
- // Delete from server if requested
- if (deleteFromServer) {
- lifecycleScope.launch {
- try {
- val webdavService = WebDavSyncService(this@MainActivity)
- val success = webdavService.deleteNoteFromServer(note.id)
- if (success) {
- runOnUiThread {
- Toast.makeText(
- this@MainActivity,
- getString(R.string.snackbar_deleted_from_server),
- Toast.LENGTH_SHORT
- ).show()
- }
- } else {
- runOnUiThread {
- Toast.makeText(
- this@MainActivity,
- getString(R.string.snackbar_server_delete_failed),
- Toast.LENGTH_LONG
- ).show()
- }
- }
- } catch (e: Exception) {
- runOnUiThread {
- Toast.makeText(
- this@MainActivity,
- "Server-Fehler: ${e.message}",
- Toast.LENGTH_LONG
- ).show()
- }
- }
- }
- }
- }
- }
- }).show()
- }
-
- /**
- * v1.4.0: Setup FAB mit Dropdown fΓΌr Notiz-Typ Auswahl
- */
- private fun setupFab() {
- fabAddNote.setOnClickListener { view ->
- showNoteTypePopup(view)
- }
- }
-
- /**
- * v1.4.0: Zeigt Popup-MenΓΌ zur Auswahl des Notiz-Typs
- */
- private fun showNoteTypePopup(anchor: View) {
- val popupMenu = PopupMenu(this, anchor, Gravity.END)
- popupMenu.inflate(R.menu.menu_fab_note_types)
-
- // Icons im Popup anzeigen (via Reflection, da standardmΓ€Γig ausgeblendet)
- try {
- val fields = popupMenu.javaClass.declaredFields
- for (field in fields) {
- if ("mPopup" == field.name) {
- field.isAccessible = true
- val menuPopupHelper = field.get(popupMenu)
- val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
- val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
- setForceIcons.invoke(menuPopupHelper, true)
- break
- }
- }
- } catch (e: Exception) {
- Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
- }
-
- popupMenu.setOnMenuItemClickListener { menuItem ->
- val noteType = when (menuItem.itemId) {
- R.id.action_create_text_note -> NoteType.TEXT
- R.id.action_create_checklist -> NoteType.CHECKLIST
- else -> return@setOnMenuItemClickListener false
- }
-
- val intent = Intent(this, NoteEditorActivity::class.java)
- intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
- startActivity(intent)
- true
- }
-
- popupMenu.show()
- }
-
- private fun loadNotes() {
- val notes = storage.loadAllNotes()
-
- // Filter out notes that are pending deletion (prevent flicker)
- val filteredNotes = notes.filter { it.id !in pendingDeletions }
-
- // Submit list with callback to scroll to top after list is updated
- adapter.submitList(filteredNotes) {
- // Scroll to top after list update is complete
- // Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
- if (filteredNotes.isNotEmpty()) {
- recyclerViewNotes.scrollToPosition(0)
- }
- }
-
- // Material 3 Empty State Card
- emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
- android.view.View.VISIBLE
- } else {
- android.view.View.GONE
- }
- }
-
- private fun openNoteEditor(noteId: String?) {
- val intent = Intent(this, NoteEditorActivity::class.java)
- noteId?.let {
- intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
- }
- startActivity(intent)
- }
-
- private fun openSettings() {
- // v1.5.0: Use new Jetpack Compose Settings
- val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
- @Suppress("DEPRECATION")
- startActivityForResult(intent, REQUEST_SETTINGS)
- }
-
- private fun triggerManualSync() {
- // π v1.3.1: Check if sync already running (Banner zeigt Status)
- if (!SyncStateManager.tryStartSync("manual")) {
- return
- }
-
- lifecycleScope.launch {
- try {
- // Create sync service
- val syncService = WebDavSyncService(this@MainActivity)
-
- // π₯ v1.1.2: Check if there are unsynced changes first (performance optimization)
- if (!syncService.hasUnsyncedChanges()) {
- Logger.d(TAG, "βοΈ Manual Sync: No unsynced changes - skipping")
- val message = getString(R.string.toast_already_synced)
- SyncStateManager.markCompleted(message)
- return@launch
- }
-
- // β WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
- val isReachable = withContext(Dispatchers.IO) {
- syncService.isServerReachable()
- }
-
- if (!isReachable) {
- Logger.d(TAG, "βοΈ Manual Sync: Server not reachable - aborting")
- SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
- return@launch
- }
-
- // Server ist erreichbar β Sync durchfΓΌhren
- val result = withContext(Dispatchers.IO) {
- syncService.syncNotes()
- }
-
- // Show result
- if (result.isSuccess) {
- SyncStateManager.markCompleted("${result.syncedCount} Notizen")
- loadNotes() // Reload notes
- } else {
- SyncStateManager.markError(result.errorMessage)
- }
-
- } catch (e: Exception) {
- SyncStateManager.markError(e.message)
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- optionsMenu = menu // π v1.3.1: Store reference for sync button state
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_settings -> {
- openSettings()
- true
- }
- R.id.action_sync -> {
- triggerManualSync()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun requestNotificationPermission() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
- != PackageManager.PERMISSION_GRANTED) {
- requestPermissions(
- arrayOf(Manifest.permission.POST_NOTIFICATIONS),
- REQUEST_NOTIFICATION_PERMISSION
- )
- }
- }
- }
-
- @Deprecated("Deprecated in Java")
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- super.onActivityResult(requestCode, resultCode, data)
-
- if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
- // Restore was successful, reload notes
- loadNotes()
- }
- }
-
- /**
- * v1.4.1: Migriert bestehende Checklisten fΓΌr AbwΓ€rtskompatibilitΓ€t.
- *
- * Problem: v1.4.0 Checklisten haben leeren "content", was auf Γ€lteren
- * App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
- *
- * LΓΆsung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
- * damit sie beim nΓ€chsten Sync mit Fallback-Content hochgeladen werden.
- *
- * TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
- * im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
- * Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
- */
- private fun migrateChecklistsForBackwardsCompat() {
- val migrationKey = "v1.4.1_checklist_migration_done"
-
- // Nur einmal ausfΓΌhren
- if (prefs.getBoolean(migrationKey, false)) {
- return
- }
-
- val allNotes = storage.loadAllNotes()
- val checklistsToMigrate = allNotes.filter { note ->
- note.noteType == NoteType.CHECKLIST &&
- note.content.isBlank() &&
- note.checklistItems?.isNotEmpty() == true
- }
-
- if (checklistsToMigrate.isNotEmpty()) {
- Logger.d(TAG, "π v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
-
- for (note in checklistsToMigrate) {
- // Als PENDING markieren, damit beim nΓ€chsten Sync der Fallback-Content
- // generiert und hochgeladen wird
- val updatedNote = note.copy(
- syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
- )
- storage.saveNote(updatedNote)
- Logger.d(TAG, " π Marked for re-sync: ${note.title}")
- }
-
- Logger.d(TAG, "β
v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
- }
-
- // Migration als erledigt markieren
- prefs.edit().putBoolean(migrationKey, true).apply()
- }
-
- override fun onRequestPermissionsResult(
- requestCode: Int,
- permissions: Array,
- grantResults: IntArray
- ) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults)
-
- when (requestCode) {
- REQUEST_NOTIFICATION_PERMISSION -> {
- if (grantResults.isNotEmpty() &&
- grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- showToast(getString(R.string.toast_notifications_enabled))
- } else {
- showToast(getString(R.string.toast_notifications_disabled))
- }
- }
- }
- }
-
- /**
- * π v1.7.2: Debug-Logging fΓΌr Locale-Problem
- * Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
- */
- private fun logLocaleInfo() {
- if (!BuildConfig.DEBUG) return
-
- Logger.d(TAG, "ββββββββββββββββββββββββββββββββββββββββββββββββββββ")
- Logger.d(TAG, "β π LOCALE DEBUG INFO")
- Logger.d(TAG, "β βββββββββββββββββββββββββββββββββββββββββββββββββββ")
-
- // System Locale
- val systemLocale = java.util.Locale.getDefault()
- Logger.d(TAG, "β System Locale (Locale.getDefault()): $systemLocale")
-
- // Resources Locale
- val resourcesLocale = resources.configuration.locales[0]
- Logger.d(TAG, "β Resources Locale: $resourcesLocale")
-
- // Context Locale (API 24+)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- val contextLocales = resources.configuration.locales
- Logger.d(TAG, "β Context Locales (all): $contextLocales")
- }
-
- // Test String Loading
- val testString = getString(R.string.toast_already_synced)
- Logger.d(TAG, "β Test: getString(R.string.toast_already_synced)")
- Logger.d(TAG, "β Result: '$testString'")
- Logger.d(TAG, "β Expected EN: 'β
Already synced'")
- Logger.d(TAG, "β Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
-
- Logger.d(TAG, "ββββββββββββββββββββββββββββββββββββββββββββββββββββ")
- }
-}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
index 3e82256..bd4cb0c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
@@ -7,6 +7,7 @@ import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -24,14 +25,17 @@ import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import org.koin.java.KoinJavaComponent
/**
* Editor Activity fΓΌr Notizen und Checklisten
- *
+ *
* v1.4.0: UnterstΓΌtzt jetzt sowohl TEXT als auch CHECKLIST Notizen
*/
class NoteEditorActivity : AppCompatActivity() {
-
+
// Views
private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout
@@ -41,38 +45,36 @@ class NoteEditorActivity : AppCompatActivity() {
private lateinit var checklistContainer: LinearLayout
private lateinit var rvChecklistItems: RecyclerView
private lateinit var btnAddItem: MaterialButton
-
- private lateinit var storage: NotesStorage
-
+
// State
private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
private val checklistItems = mutableListOf()
private var checklistAdapter: ChecklistEditorAdapter? = null
private var itemTouchHelper: ItemTouchHelper? = null
-
+
companion object {
private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
}
-
+
+ private val storage: NotesStorage by KoinJavaComponent.inject(NotesStorage::class.java)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
+
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
-
+
setContentView(R.layout.activity_editor)
-
- storage = NotesStorage(this)
-
+
findViews()
setupToolbar()
loadNoteOrDetermineType()
setupUIForNoteType()
}
-
+
private fun findViews() {
toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle)
@@ -83,33 +85,36 @@ class NoteEditorActivity : AppCompatActivity() {
rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem)
}
-
+
private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
-
+
private fun loadNoteOrDetermineType() {
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
-
+
if (noteId != null) {
- // Existierende Notiz laden
- existingNote = storage.loadNote(noteId)
- existingNote?.let { note ->
- editTextTitle.setText(note.title)
- currentNoteType = note.noteType
-
- when (note.noteType) {
- NoteType.TEXT -> {
- editTextContent.setText(note.content)
- supportActionBar?.title = getString(R.string.edit_note)
- }
- NoteType.CHECKLIST -> {
- note.checklistItems?.let { items ->
- checklistItems.clear()
- checklistItems.addAll(items.sortedBy { it.order })
+
+ lifecycleScope.launch {
+ // Existierende Notiz laden
+ existingNote = storage.loadNote(noteId)
+ existingNote?.let { note ->
+ editTextTitle.setText(note.title)
+ currentNoteType = note.noteType
+
+ when (note.noteType) {
+ NoteType.TEXT -> {
+ editTextContent.setText(note.content)
+ supportActionBar?.title = getString(R.string.edit_note)
+ }
+ NoteType.CHECKLIST -> {
+ note.checklistItems?.let { items ->
+ checklistItems.clear()
+ checklistItems.addAll(items.sortedBy { it.order })
+ }
+ supportActionBar?.title = getString(R.string.edit_checklist)
}
- supportActionBar?.title = getString(R.string.edit_checklist)
}
}
}
@@ -122,7 +127,7 @@ class NoteEditorActivity : AppCompatActivity() {
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
-
+
when (currentNoteType) {
NoteType.TEXT -> {
supportActionBar?.title = getString(R.string.new_note)
@@ -135,7 +140,7 @@ class NoteEditorActivity : AppCompatActivity() {
}
}
}
-
+
private fun setupUIForNoteType() {
when (currentNoteType) {
NoteType.TEXT -> {
@@ -149,7 +154,7 @@ class NoteEditorActivity : AppCompatActivity() {
}
}
}
-
+
private fun setupChecklistRecyclerView() {
checklistAdapter = ChecklistEditorAdapter(
items = checklistItems,
@@ -173,12 +178,12 @@ class NoteEditorActivity : AppCompatActivity() {
itemTouchHelper?.startDrag(viewHolder)
}
)
-
+
rvChecklistItems.apply {
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
adapter = checklistAdapter
}
-
+
// Drag & Drop Setup
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
@@ -194,48 +199,48 @@ class NoteEditorActivity : AppCompatActivity() {
checklistAdapter?.moveItem(from, to)
return true
}
-
+
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Nicht verwendet
}
-
+
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
}
-
+
itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
-
+
// Add Item Button
btnAddItem.setOnClickListener {
addChecklistItemAt(checklistItems.size)
}
}
-
+
private fun addChecklistItemAt(position: Int) {
val newItem = ChecklistItem.createEmpty(position)
checklistAdapter?.insertItem(position, newItem)
-
+
// Zum neuen Item scrollen und fokussieren
rvChecklistItems.scrollToPosition(position)
checklistAdapter?.focusItem(rvChecklistItems, position)
}
-
+
private fun deleteChecklistItem(position: Int) {
checklistAdapter?.removeItem(position)
-
+
// Wenn letztes Item gelΓΆscht, automatisch neues hinzufΓΌgen
if (checklistItems.isEmpty()) {
addChecklistItemAt(0)
}
}
-
+
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu)
// Delete nur fΓΌr existierende Notizen
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true
}
-
+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@@ -253,19 +258,19 @@ class NoteEditorActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}
-
+
private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: ""
-
+
when (currentNoteType) {
NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: ""
-
+
if (title.isEmpty() && content.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
-
+
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
@@ -285,24 +290,24 @@ class NoteEditorActivity : AppCompatActivity() {
syncStatus = SyncStatus.LOCAL_ONLY
)
}
-
- storage.saveNote(note)
+
+ lifecycleScope.launch { storage.saveNote(note) }
}
-
+
NoteType.CHECKLIST -> {
// Leere Items filtern
val validItems = checklistItems.filter { it.text.isNotBlank() }
-
+
if (title.isEmpty() && validItems.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
-
+
// Order neu setzen
val orderedItems = validItems.mapIndexed { index, item ->
item.copy(order = index)
}
-
+
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
@@ -322,15 +327,15 @@ class NoteEditorActivity : AppCompatActivity() {
syncStatus = SyncStatus.LOCAL_ONLY
)
}
-
- storage.saveNote(note)
+
+ lifecycleScope.launch { storage.saveNote(note) }
}
}
-
+
showToast(getString(R.string.note_saved))
finish()
}
-
+
private fun confirmDelete() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.delete_note_title))
@@ -341,10 +346,10 @@ class NoteEditorActivity : AppCompatActivity() {
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
-
+
private fun deleteNote() {
existingNote?.let {
- storage.deleteNote(it.id)
+ lifecycleScope.launch { storage.deleteNote(it.id) }
showToast(getString(R.string.note_deleted))
finish()
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
index 1fa509d..acf27ff 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
@@ -6,6 +6,7 @@ import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
@@ -32,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
+import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -40,14 +42,16 @@ import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast
+import org.koin.java.KoinJavaComponent.inject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
+import kotlin.getValue
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
class SettingsActivity : AppCompatActivity() {
-
+
companion object {
private const val TAG = "SettingsActivity"
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
@@ -55,7 +59,7 @@ class SettingsActivity : AppCompatActivity() {
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
private const val CONNECTION_TIMEOUT_MS = 3000
}
-
+
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
private lateinit var editTextServerUrl: EditText
private lateinit var editTextUsername: EditText
@@ -70,55 +74,54 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var buttonManualMarkdownSync: Button
private lateinit var textViewServerStatus: TextView
private lateinit var textViewManualSyncInfo: TextView
-
+
// Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup
private lateinit var radioHttp: RadioButton
private lateinit var radioHttps: RadioButton
private lateinit var protocolHintText: TextView
-
+
// Sync Interval UI
private lateinit var radioGroupSyncInterval: RadioGroup
-
+
// About Section UI
private lateinit var textViewAppVersion: TextView
private lateinit var cardGitHubRepo: MaterialCardView
private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: MaterialCardView
-
+
// Debug Section UI
private lateinit var switchFileLogging: com.google.android.material.materialswitch.MaterialSwitch
private lateinit var buttonExportLogs: Button
private lateinit var buttonClearLogs: Button
-
+
// Backup Manager
private val backupManager by lazy { BackupManager(this) }
-
+
// Activity Result Launchers
private val createBackupLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { createBackup(it) }
}
-
+
private val restoreBackupLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
}
-
- private val prefs by lazy {
- getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
- }
-
+
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
+
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
-
+
setContentView(R.layout.activity_settings)
-
+
// Setup toolbar
val toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
@@ -126,7 +129,7 @@ class SettingsActivity : AppCompatActivity() {
setDisplayHomeAsUpEnabled(true)
title = "Einstellungen"
}
-
+
findViews()
loadSettings()
setupListeners()
@@ -134,7 +137,7 @@ class SettingsActivity : AppCompatActivity() {
setupAboutSection()
setupDebugSection()
}
-
+
private fun findViews() {
textInputLayoutServerUrl = findViewById(R.id.textInputLayoutServerUrl)
editTextServerUrl = findViewById(R.id.editTextServerUrl)
@@ -150,42 +153,42 @@ class SettingsActivity : AppCompatActivity() {
buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
-
+
// Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
radioHttp = findViewById(R.id.radioHttp)
radioHttps = findViewById(R.id.radioHttps)
protocolHintText = findViewById(R.id.protocolHintText)
-
+
// Sync Interval UI
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
-
+
// About Section UI
textViewAppVersion = findViewById(R.id.textViewAppVersion)
cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
cardLicense = findViewById(R.id.cardLicense)
-
+
// Debug Section UI
switchFileLogging = findViewById(R.id.switchFileLogging)
buttonExportLogs = findViewById(R.id.buttonExportLogs)
buttonClearLogs = findViewById(R.id.buttonClearLogs)
}
-
+
private fun loadSettings() {
val savedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
-
+
// Parse existing URL to extract protocol and host/path
if (savedUrl.isNotEmpty()) {
val (protocol, hostPath) = parseUrl(savedUrl)
-
+
// Set protocol radio button
when (protocol) {
"http" -> radioHttp.isChecked = true
"https" -> radioHttps.isChecked = true
else -> radioHttp.isChecked = true // Default to HTTP (most users have local servers)
}
-
+
// Set URL with protocol prefix in the text field
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$protocol://$hostPath")
@@ -195,26 +198,26 @@ class SettingsActivity : AppCompatActivity() {
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("http://")
}
-
+
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
-
+
// Load Markdown Auto-Sync (backward compatible)
val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
val markdownAutoSync = markdownExport && markdownAutoImport
switchMarkdownAutoSync.isChecked = markdownAutoSync
-
+
updateMarkdownButtonVisibility()
-
+
// Update hint text based on selected protocol
updateProtocolHint()
-
+
// Server Status prΓΌfen
checkServerStatus()
}
-
+
/**
* Parse URL into protocol and host/path components
* @param url Full URL like "https://example.com:8080/webdav"
@@ -227,7 +230,7 @@ class SettingsActivity : AppCompatActivity() {
else -> "http" to url // Default to HTTP if no protocol specified
}
}
-
+
/**
* Update the hint text below protocol selection based on selected protocol
*/
@@ -238,7 +241,7 @@ class SettingsActivity : AppCompatActivity() {
getString(R.string.server_connection_https_hint)
}
}
-
+
/**
* Update protocol prefix in URL field when radio button changes
* Keeps the host/path part, only changes http:// <-> https://
@@ -246,39 +249,39 @@ class SettingsActivity : AppCompatActivity() {
private fun updateProtocolInUrl() {
val currentText = editTextServerUrl.text.toString()
val newProtocol = if (radioHttp.isChecked) "http" else "https"
-
+
// Extract host/path without protocol
val hostPath = when {
currentText.startsWith("https://") -> currentText.removePrefix("https://")
currentText.startsWith("http://") -> currentText.removePrefix("http://")
else -> currentText
}
-
+
// Set new URL with correct protocol
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$newProtocol://$hostPath")
-
+
// Move cursor to end
editTextServerUrl.setSelection(editTextServerUrl.text?.length ?: 0)
}
-
+
private fun setupListeners() {
// Protocol selection listener - update URL prefix when radio changes
protocolRadioGroup.setOnCheckedChangeListener { _, checkedId ->
updateProtocolInUrl()
updateProtocolHint()
}
-
+
buttonTestConnection.setOnClickListener {
saveSettings()
testConnection()
}
-
+
buttonSyncNow.setOnClickListener {
saveSettings()
syncNow()
}
-
+
buttonCreateBackup.setOnClickListener {
// Dateiname mit Timestamp
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
@@ -286,28 +289,28 @@ class SettingsActivity : AppCompatActivity() {
val filename = "simplenotes_backup_$timestamp.json"
createBackupLauncher.launch(filename)
}
-
+
buttonRestoreFromFile.setOnClickListener {
restoreBackupLauncher.launch(arrayOf("application/json"))
}
-
+
buttonRestoreFromServer.setOnClickListener {
saveSettings()
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
-
+
buttonManualMarkdownSync.setOnClickListener {
performManualMarkdownSync()
}
-
+
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
}
-
+
switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
onMarkdownAutoSyncToggled(isChecked)
}
-
+
// Clear error when user starts typing again
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@@ -316,7 +319,7 @@ class SettingsActivity : AppCompatActivity() {
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
-
+
// Server Status Check bei Settings-Γnderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
@@ -324,7 +327,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Setup sync interval picker with radio buttons
*/
@@ -334,7 +337,7 @@ class SettingsActivity : AppCompatActivity() {
Constants.PREF_SYNC_INTERVAL_MINUTES,
Constants.DEFAULT_SYNC_INTERVAL_MINUTES
)
-
+
// Set checked radio button based on current interval
val checkedId = when (currentInterval) {
15L -> R.id.radioInterval15
@@ -343,7 +346,7 @@ class SettingsActivity : AppCompatActivity() {
else -> R.id.radioInterval30 // Default
}
radioGroupSyncInterval.check(checkedId)
-
+
// Listen for interval changes
radioGroupSyncInterval.setOnCheckedChangeListener { _, checkedId ->
val newInterval = when (checkedId) {
@@ -351,15 +354,15 @@ class SettingsActivity : AppCompatActivity() {
R.id.radioInterval60 -> 60L
else -> 30L // R.id.radioInterval30 or fallback
}
-
+
// Save new interval to preferences
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, newInterval).apply()
-
+
// Restart periodic sync with new interval (only if auto-sync is enabled)
if (prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) {
val networkMonitor = NetworkMonitor(this)
networkMonitor.startMonitoring()
-
+
val intervalText = when (newInterval) {
15L -> "15 Minuten"
30L -> "30 Minuten"
@@ -373,7 +376,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Setup about section with version info and clickable cards
*/
@@ -382,29 +385,29 @@ class SettingsActivity : AppCompatActivity() {
try {
val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE
-
+
textViewAppVersion.text = getString(R.string.about_version, versionName, versionCode)
} catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = getString(R.string.version_not_available)
}
-
+
// GitHub Repository Card
cardGitHubRepo.setOnClickListener {
openUrl(GITHUB_REPO_URL)
}
-
+
// Developer Profile Card
cardDeveloperProfile.setOnClickListener {
openUrl(GITHUB_PROFILE_URL)
}
-
+
// License Card
cardLicense.setOnClickListener {
openUrl(LICENSE_URL)
}
}
-
+
/**
* Setup Debug section with file logging toggle and export functionality
*/
@@ -412,15 +415,15 @@ class SettingsActivity : AppCompatActivity() {
// Load current file logging state
val fileLoggingEnabled = prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
switchFileLogging.isChecked = fileLoggingEnabled
-
+
// Update Logger state
Logger.setFileLoggingEnabled(fileLoggingEnabled)
-
+
// Toggle file logging
switchFileLogging.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, isChecked).apply()
Logger.setFileLoggingEnabled(isChecked)
-
+
if (isChecked) {
showToast("π Datei-Logging aktiviert")
Logger.i(TAG, "File logging enabled by user")
@@ -428,18 +431,18 @@ class SettingsActivity : AppCompatActivity() {
showToast("π Datei-Logging deaktiviert")
}
}
-
+
// Export logs button
buttonExportLogs.setOnClickListener {
exportAndShareLogs()
}
-
+
// Clear logs button
buttonClearLogs.setOnClickListener {
showClearLogsConfirmation()
}
}
-
+
/**
* Export logs and share via system share sheet
*/
@@ -447,36 +450,36 @@ class SettingsActivity : AppCompatActivity() {
lifecycleScope.launch {
try {
val logFile = Logger.getLogFile(this@SettingsActivity)
-
+
if (logFile == null || !logFile.exists() || logFile.length() == 0L) {
showToast("π Keine Logs vorhanden")
return@launch
}
-
+
// Create share intent using FileProvider
val logUri = FileProvider.getUriForFile(
this@SettingsActivity,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
-
+
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, "SimpleNotes Sync Logs")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
-
+
startActivity(Intent.createChooser(shareIntent, "Logs teilen via..."))
Logger.i(TAG, "Logs exported and shared")
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to export logs", e)
showToast("β Fehler beim Exportieren: ${e.message}")
}
}
}
-
+
/**
* Show confirmation dialog before clearing logs
*/
@@ -490,7 +493,7 @@ class SettingsActivity : AppCompatActivity() {
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
-
+
/**
* Clear all log files
*/
@@ -507,7 +510,7 @@ class SettingsActivity : AppCompatActivity() {
showToast(getString(R.string.toast_logs_delete_error, e.message ?: ""))
}
}
-
+
/**
* Opens URL in browser
*/
@@ -520,15 +523,15 @@ class SettingsActivity : AppCompatActivity() {
showToast(getString(R.string.toast_link_error))
}
}
-
+
private fun saveSettings() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
-
+
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
-
+
// π₯ v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
@@ -539,7 +542,7 @@ class SettingsActivity : AppCompatActivity() {
return
}
}
-
+
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, fullUrl)
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
@@ -548,15 +551,15 @@ class SettingsActivity : AppCompatActivity() {
apply()
}
}
-
+
private fun testConnection() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
-
+
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
-
+
// π₯ v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
@@ -567,13 +570,13 @@ class SettingsActivity : AppCompatActivity() {
return
}
}
-
+
lifecycleScope.launch {
try {
showToast("Teste Verbindung...")
val syncService = WebDavSyncService(this@SettingsActivity)
val result = syncService.testConnection()
-
+
if (result.isSuccess) {
showToast("Verbindung erfolgreich!")
checkServerStatus() // β
Server-Status sofort aktualisieren
@@ -587,29 +590,29 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
private fun syncNow() {
// π v1.3.1: Check if sync already running (Button wird deaktiviert)
if (!SyncStateManager.tryStartSync("settings")) {
return
}
-
+
// Disable button during sync
buttonSyncNow.isEnabled = false
-
+
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@SettingsActivity)
-
+
// π₯ v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
showToast(getString(R.string.toast_already_synced))
SyncStateManager.markCompleted()
return@launch
}
-
+
showToast("π Synchronisiere...")
-
+
// β WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) {
showToast("β οΈ ${getString(R.string.snackbar_server_unreachable)}")
@@ -617,9 +620,9 @@ class SettingsActivity : AppCompatActivity() {
checkServerStatus() // Server-Status aktualisieren
return@launch
}
-
+
val result = syncService.syncNotes()
-
+
if (result.isSuccess) {
if (result.hasConflicts) {
showToast("β
Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
@@ -643,19 +646,19 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
private fun checkServerStatus() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
-
+
if (serverUrl.isNullOrEmpty()) {
textViewServerStatus.text = getString(R.string.server_status_not_configured)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
return
}
-
+
textViewServerStatus.text = getString(R.string.status_checking)
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
-
+
lifecycleScope.launch {
val isReachable = withContext(Dispatchers.IO) {
try {
@@ -671,7 +674,7 @@ class SettingsActivity : AppCompatActivity() {
false
}
}
-
+
if (isReachable) {
textViewServerStatus.text = getString(R.string.server_status_reachable)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
@@ -681,10 +684,10 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
private fun onAutoSyncToggled(enabled: Boolean) {
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
-
+
if (enabled) {
showToast("Auto-Sync aktiviert")
checkBatteryOptimization()
@@ -694,15 +697,15 @@ class SettingsActivity : AppCompatActivity() {
restartNetworkMonitor()
}
}
-
+
private fun onMarkdownAutoSyncToggled(enabled: Boolean) {
if (enabled) {
// Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch {
try {
- val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(this@SettingsActivity)
- val currentNoteCount = noteStorage.loadAllNotes().size
-
+
+ val currentNoteCount = storage.loadAllNotes().size
+
if (currentNoteCount > 0) {
// Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
@@ -714,20 +717,20 @@ class SettingsActivity : AppCompatActivity() {
setCancelable(false)
show()
}
-
+
try {
// Hole Server-Daten
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
-
+
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
progressDialog.dismiss()
showToast("β οΈ Bitte zuerst WebDAV-Server konfigurieren")
switchMarkdownAutoSync.isChecked = false
return@launch
}
-
+
// FΓΌhre Initial-Export aus
val syncService = WebDavSyncService(this@SettingsActivity)
val exportedCount = syncService.exportAllNotesToMarkdown(
@@ -741,24 +744,24 @@ class SettingsActivity : AppCompatActivity() {
}
}
)
-
+
progressDialog.dismiss()
-
+
// Speichere beide Einstellungen
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
-
+
updateMarkdownButtonVisibility()
-
+
// Erfolgs-Nachricht
showToast("β
$exportedCount Notizen nach Markdown exportiert")
-
+
} catch (e: Exception) {
progressDialog.dismiss()
showToast("β Export fehlgeschlagen: ${e.message}")
-
+
// Deaktiviere Toggle bei Fehler
switchMarkdownAutoSync.isChecked = false
return@launch
@@ -769,14 +772,14 @@ class SettingsActivity : AppCompatActivity() {
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
-
+
updateMarkdownButtonVisibility()
showToast(
"Markdown Auto-Sync aktiviert - " +
"Notizen werden als .md-Dateien exportiert und importiert"
)
}
-
+
} catch (e: Exception) {
Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}")
showToast("Fehler: ${e.message}")
@@ -789,21 +792,21 @@ class SettingsActivity : AppCompatActivity() {
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
-
+
updateMarkdownButtonVisibility()
showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
}
}
-
+
private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
val packageName = packageName
-
+
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
showBatteryOptimizationDialog()
}
}
-
+
private fun showBatteryOptimizationDialog() {
AlertDialog.Builder(this)
.setTitle("Hintergrund-Synchronisation")
@@ -821,7 +824,7 @@ class SettingsActivity : AppCompatActivity() {
.setCancelable(false)
.show()
}
-
+
/**
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
* For Play Store builds, this would need to be changed to
@@ -845,7 +848,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
private fun restartNetworkMonitor() {
try {
val app = application as SimpleNotesApplication
@@ -858,7 +861,7 @@ class SettingsActivity : AppCompatActivity() {
showToast("Fehler beim Neustart des NetworkMonitors")
}
}
-
+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@@ -869,16 +872,16 @@ class SettingsActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}
-
+
override fun onPause() {
super.onPause()
saveSettings()
}
-
+
// ========================================
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
// ========================================
-
+
/**
* Restore-Quelle (Lokale Datei oder WebDAV Server)
*/
@@ -886,7 +889,7 @@ class SettingsActivity : AppCompatActivity() {
LOCAL_FILE,
WEBDAV_SERVER
}
-
+
/**
* Erstellt Backup (Task #1.2.0-04)
*/
@@ -895,7 +898,7 @@ class SettingsActivity : AppCompatActivity() {
try {
Logger.d(TAG, "π¦ Creating backup...")
val result = backupManager.createBackup(uri)
-
+
if (result.success) {
showToast("β
${result.message}")
} else {
@@ -907,10 +910,10 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Universeller Restore-Dialog fΓΌr beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
- *
+ *
* @param source Lokale Datei oder WebDAV Server
* @param fileUri URI der lokalen Datei (nur fΓΌr LOCAL_FILE)
*/
@@ -919,13 +922,13 @@ class SettingsActivity : AppCompatActivity() {
RestoreSource.LOCAL_FILE -> "Lokale Datei"
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
}
-
+
// Custom View mit Radio Buttons
val radioGroup = android.widget.RadioGroup(this).apply {
orientation = android.widget.RadioGroup.VERTICAL
setPadding(50, 20, 50, 20)
}
-
+
// Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply {
text = getString(R.string.backup_mode_merge_full)
@@ -933,29 +936,29 @@ class SettingsActivity : AppCompatActivity() {
isChecked = true
setPadding(10, 10, 10, 10)
}
-
+
val radioReplace = android.widget.RadioButton(this).apply {
text = getString(R.string.backup_mode_replace_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
-
+
val radioOverwrite = android.widget.RadioButton(this).apply {
text = getString(R.string.backup_mode_overwrite_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
-
+
radioGroup.addView(radioMerge)
radioGroup.addView(radioReplace)
radioGroup.addView(radioOverwrite)
-
+
// Hauptlayout
val mainLayout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(50, 30, 50, 30)
}
-
+
// Info Text
@Suppress("SetTextI18n") // Programmatically generated dialog text
val infoText = android.widget.TextView(this).apply {
@@ -963,7 +966,7 @@ class SettingsActivity : AppCompatActivity() {
textSize = 16f
setPadding(0, 0, 0, 20)
}
-
+
// Hinweis Text
val hintText = android.widget.TextView(this).apply {
text = getString(R.string.backup_restore_info)
@@ -971,11 +974,11 @@ class SettingsActivity : AppCompatActivity() {
setTypeface(null, android.graphics.Typeface.ITALIC)
setPadding(0, 20, 0, 0)
}
-
+
mainLayout.addView(infoText)
mainLayout.addView(radioGroup)
mainLayout.addView(hintText)
-
+
// Dialog erstellen
AlertDialog.Builder(this)
.setTitle("β οΈ Backup wiederherstellen?")
@@ -986,7 +989,7 @@ class SettingsActivity : AppCompatActivity() {
radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
else -> RestoreMode.MERGE
}
-
+
when (source) {
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
@@ -995,7 +998,7 @@ class SettingsActivity : AppCompatActivity() {
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
-
+
/**
* FΓΌhrt Restore aus lokaler Datei durch (Task #1.2.0-05)
*/
@@ -1006,17 +1009,17 @@ class SettingsActivity : AppCompatActivity() {
setCancelable(false)
show()
}
-
+
try {
Logger.d(TAG, "π₯ Restoring from file: $uri (mode: $mode)")
val result = backupManager.restoreBackup(uri, mode)
-
+
progressDialog.dismiss()
-
+
if (result.success) {
val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen"
showToast("β
$message")
-
+
// Refresh MainActivity's note list
setResult(RESULT_OK)
broadcastNotesChanged(result.importedNotes)
@@ -1030,7 +1033,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Server-Restore mit Restore-Modi (v1.3.0)
*/
@@ -1041,24 +1044,24 @@ class SettingsActivity : AppCompatActivity() {
setCancelable(false)
show()
}
-
+
try {
Logger.d(TAG, "π₯ Restoring from server (mode: $mode)")
-
+
// Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "β οΈ Auto-backup failed, but continuing with restore")
}
-
+
// Server-Restore durchfΓΌhren
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
webdavService.restoreFromServer(mode) // β
Pass mode parameter
}
-
+
progressDialog.dismiss()
-
+
if (result.isSuccess) {
showToast("β
Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK)
@@ -1073,7 +1076,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Sendet Broadcast dass Notizen geΓ€ndert wurden
*/
@@ -1083,18 +1086,18 @@ class SettingsActivity : AppCompatActivity() {
intent.putExtra("syncedCount", count)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
-
+
/**
* Updates visibility of manual sync button based on Auto-Sync toggle state
*/
private fun updateMarkdownButtonVisibility() {
val autoSyncEnabled = switchMarkdownAutoSync.isChecked
val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE
-
+
textViewManualSyncInfo.visibility = visibility
buttonManualMarkdownSync.visibility = visibility
}
-
+
/**
* Performs manual Markdown sync (Export + Import)
* Called when manual sync button is clicked
@@ -1107,12 +1110,12 @@ class SettingsActivity : AppCompatActivity() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "")
val username = prefs.getString(Constants.KEY_USERNAME, "")
val password = prefs.getString(Constants.KEY_PASSWORD, "")
-
+
if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) {
showToast("β οΈ Bitte zuerst WebDAV-Server konfigurieren")
return@launch
}
-
+
// Progress-Dialog
progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Sync")
@@ -1120,25 +1123,25 @@ class SettingsActivity : AppCompatActivity() {
setCancelable(false)
show()
}
-
+
// Sync ausfΓΌhren
val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity)
val result = syncService.manualMarkdownSync()
-
+
progressDialog.dismiss()
-
+
// Erfolgs-Nachricht
val message = "β
Sync abgeschlossen\n" +
"π€ ${result.exportedCount} exportiert\n" +
"π₯ ${result.importedCount} importiert"
showToast(message)
-
+
Logger.d(
"SettingsActivity",
"Manual markdown sync: exported=${result.exportedCount}, " +
"imported=${result.importedCount}"
)
-
+
} catch (e: Exception) {
progressDialog?.dismiss()
showToast("β Sync fehlgeschlagen: ${e.message}")
@@ -1146,7 +1149,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
}
-
+
/**
* Zeigt Error-Dialog an
*/
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt
index 5dbd53b..5614f2b 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt
@@ -11,21 +11,24 @@ import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.java.KoinJavaComponent.inject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
+import kotlin.getValue
/**
* BackupManager: Lokale Backup & Restore FunktionalitΓ€t
- *
+ *
* Features:
* - Backup aller Notizen in JSON-Datei
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
* - Auto-Backup vor Restore (Sicherheitsnetz)
* - Backup-Validierung
*/
-class BackupManager(private val context: Context) {
-
+class BackupManager(private val context: Context): KoinComponent {
+
companion object {
private const val TAG = "BackupManager"
private const val BACKUP_VERSION = 1
@@ -33,14 +36,14 @@ class BackupManager(private val context: Context) {
private const val AUTO_BACKUP_RETENTION_DAYS = 7
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
}
-
- private val storage = NotesStorage(context)
+
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val encryptionManager = EncryptionManager() // π v1.7.0
-
+
/**
* Erstellt Backup aller Notizen
- *
+ *
* @param uri Output-URI (via Storage Access Framework)
* @param password Optional password for encryption (null = unencrypted)
* @return BackupResult mit Erfolg/Fehler Info
@@ -49,10 +52,10 @@ class BackupManager(private val context: Context) {
return@withContext try {
val encryptedSuffix = if (password != null) " (encrypted)" else ""
Logger.d(TAG, "π¦ Creating backup$encryptedSuffix to: $uri")
-
+
val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
-
+
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(),
@@ -60,27 +63,27 @@ class BackupManager(private val context: Context) {
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
-
+
val jsonString = gson.toJson(backupData)
-
+
// π v1.7.0: Encrypt if password is provided
val dataToWrite = if (password != null) {
encryptionManager.encrypt(jsonString.toByteArray(), password)
} else {
jsonString.toByteArray()
}
-
+
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(dataToWrite)
Logger.d(TAG, "β
Backup created successfully$encryptedSuffix")
}
-
+
BackupResult(
success = true,
notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
)
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
BackupResult(
@@ -89,11 +92,11 @@ class BackupManager(private val context: Context) {
)
}
}
-
+
/**
* Erstellt automatisches Backup (vor Restore)
* Gespeichert in app-internem Storage
- *
+ *
* @return Uri des Auto-Backups oder null bei Fehler
*/
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
@@ -101,14 +104,14 @@ class BackupManager(private val context: Context) {
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
if (!exists()) mkdirs()
}
-
+
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date())
val filename = "auto_backup_before_restore_$timestamp.json"
val file = File(autoBackupDir, filename)
-
+
Logger.d(TAG, "π¦ Creating auto-backup: ${file.absolutePath}")
-
+
val allNotes = storage.loadAllNotes()
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
@@ -117,24 +120,24 @@ class BackupManager(private val context: Context) {
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
-
+
file.writeText(gson.toJson(backupData))
-
+
// Cleanup alte Auto-Backups
cleanupOldAutoBackups(autoBackupDir)
-
+
Logger.d(TAG, "β
Auto-backup created: ${file.absolutePath}")
Uri.fromFile(file)
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to create auto-backup", e)
null
}
}
-
+
/**
* Stellt Notizen aus Backup wieder her
- *
+ *
* @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @param password Optional password if backup is encrypted
@@ -143,7 +146,7 @@ class BackupManager(private val context: Context) {
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "π₯ Restoring backup from: $uri (mode: $mode)")
-
+
// 1. Backup-Datei lesen
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.readBytes()
@@ -151,7 +154,7 @@ class BackupManager(private val context: Context) {
success = false,
error = "Datei konnte nicht gelesen werden"
)
-
+
// π v1.7.0: Check if encrypted and decrypt if needed
val jsonString = try {
if (encryptionManager.isEncrypted(fileData)) {
@@ -172,7 +175,7 @@ class BackupManager(private val context: Context) {
error = "EntschlΓΌsselung fehlgeschlagen: ${e.message}"
)
}
-
+
// 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) {
@@ -181,26 +184,26 @@ class BackupManager(private val context: Context) {
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
)
}
-
+
val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
-
+
// 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "β οΈ Auto-backup failed, but continuing with restore")
}
-
+
// 4. Restore durchfΓΌhren (je nach Modus)
val result = when (mode) {
RestoreMode.MERGE -> restoreMerge(backupData.notes)
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
}
-
+
Logger.d(TAG, "β
Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup", e)
RestoreResult(
@@ -209,7 +212,7 @@ class BackupManager(private val context: Context) {
)
}
}
-
+
/**
* π v1.7.0: Check if backup file is encrypted
*/
@@ -225,14 +228,14 @@ class BackupManager(private val context: Context) {
false
}
}
-
+
/**
* Validiert Backup-Datei
*/
private fun validateBackup(jsonString: String): ValidationResult {
return try {
val backupData = gson.fromJson(jsonString, BackupData::class.java)
-
+
// Version kompatibel?
if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult(
@@ -240,7 +243,7 @@ class BackupManager(private val context: Context) {
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
)
}
-
+
// Notizen-Array vorhanden?
if (backupData.notes.isEmpty()) {
return ValidationResult(
@@ -248,21 +251,21 @@ class BackupManager(private val context: Context) {
errorMessage = context.getString(R.string.error_backup_empty)
)
}
-
+
// Alle Notizen haben ID, title, content?
val invalidNotes = backupData.notes.filter { note ->
note.id.isBlank() || note.title.isBlank()
}
-
+
if (invalidNotes.isNotEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
)
}
-
+
ValidationResult(isValid = true)
-
+
} catch (e: Exception) {
ValidationResult(
isValid = false,
@@ -270,22 +273,22 @@ class BackupManager(private val context: Context) {
)
}
}
-
+
/**
* Restore-Modus: MERGE
* FΓΌgt neue Notizen hinzu, behΓ€lt bestehende
*/
- private fun restoreMerge(backupNotes: List): RestoreResult {
+ private suspend fun restoreMerge(backupNotes: List): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
-
+
val newNotes = backupNotes.filter { it.id !in existingIds }
val skippedNotes = backupNotes.size - newNotes.size
-
+
newNotes.forEach { note ->
storage.saveNote(note)
}
-
+
return RestoreResult(
success = true,
importedNotes = newNotes.size,
@@ -293,20 +296,20 @@ class BackupManager(private val context: Context) {
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
)
}
-
+
/**
* Restore-Modus: REPLACE
* LΓΆscht alle bestehenden Notizen, importiert Backup
*/
- private fun restoreReplace(backupNotes: List): RestoreResult {
+ private suspend fun restoreReplace(backupNotes: List): RestoreResult {
// Alle bestehenden Notizen lΓΆschen
storage.deleteAllNotes()
-
+
// Backup-Notizen importieren
backupNotes.forEach { note ->
storage.saveNote(note)
}
-
+
return RestoreResult(
success = true,
importedNotes = backupNotes.size,
@@ -319,18 +322,18 @@ class BackupManager(private val context: Context) {
* Restore-Modus: OVERWRITE_DUPLICATES
* Backup ΓΌberschreibt bei ID-Konflikten
*/
- private fun restoreOverwriteDuplicates(backupNotes: List): RestoreResult {
+ private suspend fun restoreOverwriteDuplicates(backupNotes: List): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
-
+
val newNotes = backupNotes.filter { it.id !in existingIds }
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
-
+
// Alle Backup-Notizen speichern (ΓΌberschreibt automatisch)
backupNotes.forEach { note ->
storage.saveNote(note)
}
-
+
return RestoreResult(
success = true,
importedNotes = newNotes.size,
@@ -339,7 +342,7 @@ class BackupManager(private val context: Context) {
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
)
}
-
+
/**
* LΓΆscht Auto-Backups Γ€lter als RETENTION_DAYS
*/
@@ -347,7 +350,7 @@ class BackupManager(private val context: Context) {
try {
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
-
+
autoBackupDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoffTime) {
Logger.d(TAG, "ποΈ Deleting old auto-backup: ${file.name}")
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt b/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt
index 3284033..ee37f10 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt
@@ -2,11 +2,11 @@ package dev.dettmer.simplenotes.di
import android.content.Context
import androidx.room.Room
-import dev.dettmer.simplenotes.MainViewModel
import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants
+import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
@@ -23,12 +23,14 @@ val appModule = module {
single { get().noteDao() }
single { get().deletedNoteDao() }
+ single { NotesStorage(androidContext(), get(), get()) }
+
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
- single { NotesStorage(androidContext(), get()) }
- viewModel { MainViewModel(get(), get(), get()) }
+
+ viewModel { MainViewModel(androidApplication()) }
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt
index 3e215c8..ad8e879 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt
@@ -1,13 +1,18 @@
package dev.dettmer.simplenotes.storage
+import android.content.Context
import androidx.room.Database
+import androidx.room.Room
import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import dev.dettmer.simplenotes.storage.converter.NoteConverters
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
+@TypeConverters(NoteConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
index baf7926..0ff8c0c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
@@ -1,59 +1,101 @@
package dev.dettmer.simplenotes.storage
import android.content.Context
+import dev.dettmer.simplenotes.models.DeletionTracker
+import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
+import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
+import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
+import java.io.File
class NotesStorage(
private val context: Context,
- database: AppDatabase
+ private val noteDao: NoteDao,
+ private val deletedNoteDao: DeletedNoteDao,
) {
companion object {
private const val TAG = "NotesStorage"
}
- private val noteDao = database.noteDao()
- private val deletedNoteDao = database.deletedNoteDao()
- suspend fun saveNote(note: NoteEntity) {
- noteDao.saveNote(note)
+ suspend fun saveNote(note: Note) {
+ noteDao.saveNote(
+ NoteEntity(
+ id = note.id,
+ title = note.title,
+ content = note.content,
+ createdAt = note.createdAt,
+ updatedAt = note.updatedAt,
+ deviceId = note.deviceId,
+ syncStatus = note.syncStatus,
+ noteType = note.noteType,
+ checklistItems = note.checklistItems,
+ checklistSortOption = note.checklistSortOption
+ )
+ )
}
- suspend fun loadNote(id: String): NoteEntity? {
- return noteDao.getNote(id)
+ suspend fun loadNote(id: String): Note? {
+ return noteDao.getNote(id)?.let { note ->
+ Note(
+ id = note.id,
+ title = note.title,
+ content = note.content,
+ createdAt = note.createdAt,
+ updatedAt = note.updatedAt,
+ deviceId = note.deviceId,
+ syncStatus = note.syncStatus,
+ noteType = note.noteType,
+ checklistItems = note.checklistItems,
+ checklistSortOption = note.checklistSortOption
+ )
+ }
}
- suspend fun loadAllNotes(): List {
- return noteDao.getAllNotes()
+ suspend fun loadAllNotes(): List {
+ return noteDao.getAllNotes().map { note ->
+ Note(
+ id = note.id,
+ title = note.title,
+ content = note.content,
+ createdAt = note.createdAt,
+ updatedAt = note.updatedAt,
+ deviceId = note.deviceId,
+ syncStatus = note.syncStatus,
+ noteType = note.noteType,
+ checklistItems = note.checklistItems,
+ checklistSortOption = note.checklistSortOption
+ )
+ }
}
suspend fun deleteNote(id: String): Boolean {
- val deletedRows = noteDao.deleteNoteById(id)
+ val deleted = noteDao.deleteNoteById(id) > 0
- if (deletedRows > 0) {
- Logger.d(TAG, "ποΈ Deleted note: $id")
+ if (deleted) {
val deviceId = DeviceIdGenerator.getDeviceId(context)
- trackDeletionSafe(id, deviceId)
- return true
+ deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId))
}
- return false
+
+ return deleted
}
suspend fun deleteAllNotes(): Boolean {
return try {
- val notes = loadAllNotes()
- val deviceId = DeviceIdGenerator.getDeviceId(context)
+ val notes = noteDao.getAllNotes()
- // Batch tracking and deleting
- notes.forEach { note ->
- trackDeletionSafe(note.id, deviceId)
- }
noteDao.deleteAllNotes()
+ for (note in notes) {
+ val deviceId = DeviceIdGenerator.getDeviceId(context)
+ deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId))
+ }
+
Logger.d(TAG, "ποΈ Deleted all notes (${notes.size} notes)")
true
} catch (e: Exception) {
@@ -64,9 +106,60 @@ class NotesStorage(
// === Deletion Tracking ===
+ private fun getDeletionTrackerFile(): File {
+ return File(context.filesDir, "deleted_notes.json")
+ }
+
+ fun loadDeletionTracker(): DeletionTracker {
+ val file = getDeletionTrackerFile()
+ if (!file.exists()) {
+ return DeletionTracker()
+ }
+
+ return try {
+ val json = file.readText()
+ DeletionTracker.fromJson(json) ?: DeletionTracker()
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to load deletion tracker", e)
+ DeletionTracker()
+ }
+ }
+
+ fun saveDeletionTracker(tracker: DeletionTracker) {
+ try {
+ val file = getDeletionTrackerFile()
+ file.writeText(tracker.toJson())
+
+ if (tracker.deletedNotes.size > 1000) {
+ Logger.w(TAG, "β οΈ Deletion tracker large: ${tracker.deletedNotes.size} entries")
+ }
+
+ Logger.d(TAG, "β
Deletion tracker saved (${tracker.deletedNotes.size} entries)")
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to save deletion tracker", e)
+ }
+ }
+
+ /**
+ * π v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
+ *
+ * Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
+ * auf den Deletion Tracker.
+ *
+ * @param noteId ID der gelΓΆschten Notiz
+ * @param deviceId GerΓ€te-ID fΓΌr Konflikt-Erkennung
+ */
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
- // Room handles internal transactions and thread-safety natively.
- // The Mutex is no longer required.
+ deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
+ }
+
+ /**
+ * Legacy-Methode ohne Mutex-Schutz.
+ * Verwendet fΓΌr synchrone Aufrufe wo Coroutines nicht verfΓΌgbar sind.
+ *
+ * @deprecated Verwende trackDeletionSafe() fΓΌr Thread-Safety wo mΓΆglich
+ */
+ suspend fun trackDeletion(noteId: String, deviceId: String) {
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
Logger.d(TAG, "π Tracked deletion: $noteId")
}
@@ -77,14 +170,17 @@ class NotesStorage(
suspend fun clearDeletionTracker() {
deletedNoteDao.clearTracker()
+
Logger.d(TAG, "ποΈ Deletion tracker cleared")
}
+ /**
+ * π v1.7.0: Reset all sync statuses to PENDING when server changes
+ * This ensures notes are uploaded to the new server on next sync
+ */
suspend fun resetAllSyncStatusToPending(): Int {
- val updatedCount = noteDao.updateSyncStatus(
- oldStatus = SyncStatus.SYNCED,
- newStatus = SyncStatus.PENDING
- )
+ var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING)
+
Logger.d(TAG, "π Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/converter/NoteConverters.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/converter/NoteConverters.kt
new file mode 100644
index 0000000..293b261
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/converter/NoteConverters.kt
@@ -0,0 +1,39 @@
+package dev.dettmer.simplenotes.storage.converter
+
+import androidx.room.TypeConverter
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import dev.dettmer.simplenotes.models.ChecklistItem
+import dev.dettmer.simplenotes.models.NoteType
+import dev.dettmer.simplenotes.models.SyncStatus
+
+class NoteConverters {
+ private val gson = Gson()
+
+ // --- NoteType Enum ---
+ @TypeConverter
+ fun fromNoteType(value: NoteType): String = value.name
+
+ @TypeConverter
+ fun toNoteType(value: String): NoteType = NoteType.valueOf(value)
+
+ // --- SyncStatus Enum ---
+ @TypeConverter
+ fun fromSyncStatus(value: SyncStatus): String = value.name
+
+ @TypeConverter
+ fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
+
+ // --- ChecklistItem List ---
+ @TypeConverter
+ fun fromChecklistItems(items: List?): String? {
+ return items?.let { gson.toJson(it) }
+ }
+
+ @TypeConverter
+ fun toChecklistItems(json: String?): List? {
+ if (json == null) return null
+ val type = object : TypeToken>() {}.type
+ return gson.fromJson(json, type)
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt
index 10ede74..70b853e 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt
@@ -2,12 +2,21 @@ package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
+import dev.dettmer.simplenotes.models.ChecklistItem
+import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
- @PrimaryKey val id: String,
+ @PrimaryKey
+ val id: String,
+ val title: String,
val content: String,
- val timestamp: Long,
- val syncStatus: SyncStatus
+ val createdAt: Long,
+ val updatedAt: Long,
+ val deviceId: String,
+ val syncStatus: SyncStatus,
+ val noteType: NoteType,
+ val checklistItems: List?, // Handled by TypeConverter
+ val checklistSortOption: String?
)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
index 281b384..cb7d90d 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
@@ -1,6 +1,7 @@
package dev.dettmer.simplenotes.sync
import android.content.Context
+import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine
@@ -21,6 +22,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
+import org.koin.java.KoinJavaComponent.inject
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Socket
@@ -35,29 +37,29 @@ data class ManualMarkdownSyncResult(
val importedCount: Int
)
-@Suppress("LargeClass")
+@Suppress("LargeClass")
// TODO v2.0.0: Split into SyncOrchestrator, NoteUploader, NoteDownloader, ConflictResolver
class WebDavSyncService(private val context: Context) {
-
+
companion object {
private const val TAG = "WebDavSyncService"
private const val SOCKET_TIMEOUT_MS = 10000 // π§ v1.7.2: 10s fΓΌr stabile Verbindungen (1s war zu kurz)
private const val MAX_FILENAME_LENGTH = 200
private const val ETAG_PREVIEW_LENGTH = 8
private const val CONTENT_PREVIEW_LENGTH = 50
-
+
// π v1.3.1: Mutex um parallele Syncs zu verhindern
private val syncMutex = Mutex()
}
-
- private val storage: NotesStorage
- private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
private var markdownDirEnsured = false // Cache fΓΌr Ordner-Existenz
private var notesDirEnsured = false // β‘ v1.3.1: Cache fΓΌr /notes/ Ordner-Existenz
-
+
// β‘ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
private var sessionSardine: SafeSardineWrapper? = null
-
+
init {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
@@ -65,35 +67,34 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
}
-
+
try {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Creating NotesStorage...")
}
- storage = NotesStorage(context)
+
if (BuildConfig.DEBUG) {
Logger.d(TAG, " β
NotesStorage created successfully")
- Logger.d(TAG, " Notes dir: ${storage.getNotesDir()}")
}
} catch (e: Exception) {
Logger.e(TAG, "π₯ CRASH in NotesStorage creation!", e)
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
throw e
}
-
+
if (BuildConfig.DEBUG) {
Logger.d(TAG, " SharedPreferences: $prefs")
Logger.d(TAG, "β
WebDavSyncService INIT complete")
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
}
}
-
+
/**
* π v1.7.1: Checks if any VPN/Wireguard interface is active.
- *
+ *
* Wireguard VPNs run as separate network interfaces (tun*, wg*, *-wg-*),
* and are NOT detected via NetworkCapabilities.TRANSPORT_VPN!
- *
+ *
* @return true if VPN interface is detected
*/
@Suppress("unused") // Reserved for future VPN detection feature
@@ -103,14 +104,14 @@ class WebDavSyncService(private val context: Context) {
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
if (!iface.isUp) continue
-
+
val name = iface.name.lowercase()
// Check for VPN/Wireguard interface patterns:
// - tun0, tun1, etc. (OpenVPN, generic VPN)
// - wg0, wg1, etc. (Wireguard)
// - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202)
- if (name.startsWith("tun") ||
- name.startsWith("wg") ||
+ if (name.startsWith("tun") ||
+ name.startsWith("wg") ||
name.contains("-wg-") ||
name.startsWith("ppp")) {
Logger.d(TAG, "π VPN interface detected: ${iface.name}")
@@ -122,31 +123,31 @@ class WebDavSyncService(private val context: Context) {
}
return false
}
-
+
/**
* β‘ v1.3.1: Gecachten Sardine-Client zurΓΌckgeben oder erstellen
* Spart ~100ms pro Aufruf durch Wiederverwendung
*/
private fun getOrCreateSardine(): Sardine? {
// Return cached if available
- sessionSardine?.let {
+ sessionSardine?.let {
Logger.d(TAG, "β‘ Reusing cached Sardine client")
- return it
+ return it
}
-
+
// Create new client
val sardine = createSardineClient()
sessionSardine = sardine
return sardine
}
-
+
/**
* Erstellt einen neuen Sardine-Client (intern)
- *
+ *
* π v1.7.2: Intelligentes Routing basierend auf Ziel-Adresse
* - Lokale Server: WiFi-Binding (bypass VPN)
* - Externe Server: Default-Routing (nutzt VPN wenn aktiv)
- *
+ *
* π§ v1.7.1: Verwendet SafeSardineWrapper statt OkHttpSardine
* - Verhindert Connection Leaks durch proper Response-Cleanup
* - Preemptive Authentication fΓΌr weniger 401-Round-Trips
@@ -154,16 +155,16 @@ class WebDavSyncService(private val context: Context) {
private fun createSardineClient(): SafeSardineWrapper? {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
-
+
Logger.d(TAG, "π§ Creating SafeSardineWrapper")
-
+
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
-
+
return SafeSardineWrapper.create(okHttpClient, username, password)
}
-
+
/**
* β‘ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
* π§ v1.7.2 (IMPL_003): SchlieΓt Sardine-Client explizit fΓΌr Resource-Cleanup
@@ -178,32 +179,32 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, "Failed to close Sardine client: ${e.message}")
}
}
-
+
sessionSardine = null
notesDirEnsured = false
markdownDirEnsured = false
Logger.d(TAG, "π§Ή Session caches cleared")
}
-
+
private fun getServerUrl(): String? {
return prefs.getString(Constants.KEY_SERVER_URL, null)
}
-
+
/**
* Erzeugt notes/ URL aus Base-URL mit Smart Detection (Task #1.2.1-12)
- *
+ *
* Beispiele:
* - http://server:8080/ β http://server:8080/notes/
* - http://server:8080/notes/ β http://server:8080/notes/
* - http://server:8080/notes β http://server:8080/notes/
* - http://server:8080/my-path/ β http://server:8080/my-path/notes/
- *
+ *
* @param baseUrl Base Server-URL
* @return notes/ Ordner-URL (mit trailing /)
*/
private fun getNotesUrl(baseUrl: String): String {
val normalized = baseUrl.trimEnd('/')
-
+
// Wenn URL bereits mit /notes endet β direkt nutzen
return if (normalized.endsWith("/notes")) {
"$normalized/"
@@ -211,53 +212,53 @@ class WebDavSyncService(private val context: Context) {
"$normalized/notes/"
}
}
-
+
/**
* Erzeugt Markdown-Ordner-URL basierend auf getNotesUrl() (Task #1.2.1-14)
- *
+ *
* Beispiele:
* - http://server:8080/ β http://server:8080/notes-md/
* - http://server:8080/notes/ β http://server:8080/notes-md/
* - http://server:8080/notes β http://server:8080/notes-md/
- *
+ *
* @param baseUrl Base Server-URL
* @return Markdown-Ordner-URL (mit trailing /)
*/
private fun getMarkdownUrl(baseUrl: String): String {
val notesUrl = getNotesUrl(baseUrl)
val normalized = notesUrl.trimEnd('/')
-
+
// Ersetze /notes mit /notes-md
return normalized.replace("/notes", "/notes-md") + "/"
}
-
+
/**
* Stellt sicher dass notes-md/ Ordner existiert
- *
+ *
* Wird beim ersten erfolgreichen Sync aufgerufen (unabhΓ€ngig von MD-Feature).
* Cached in Memory - nur einmal pro App-Session.
*/
private fun ensureMarkdownDirectoryExists(sardine: Sardine, serverUrl: String) {
if (markdownDirEnsured) return
-
+
try {
val mdUrl = getMarkdownUrl(serverUrl)
-
+
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "π Created notes-md/ directory (for future use)")
}
-
+
markdownDirEnsured = true
} catch (e: Exception) {
Logger.e(TAG, "Failed to create notes-md/: ${e.message}")
// Nicht kritisch - User kann spΓ€ter manuell erstellen
}
}
-
+
/**
* β‘ v1.3.1: Stellt sicher dass notes/ Ordner existiert (mit Cache)
- *
+ *
* Spart ~500ms pro Sync durch Caching
*/
private fun ensureNotesDirectoryExists(sardine: Sardine, notesUrl: String) {
@@ -265,7 +266,7 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "β‘ notes/ directory already verified (cached)")
return
}
-
+
try {
Logger.d(TAG, "π Checking if notes/ directory exists...")
if (!sardine.exists(notesUrl)) {
@@ -279,15 +280,15 @@ class WebDavSyncService(private val context: Context) {
throw e
}
}
-
+
/**
* Checks if server has changes using E-Tag caching
- *
+ *
* v1.3.0: Also checks /notes-md/ if Markdown Auto-Import enabled
- *
+ *
* Performance: ~100-200ms (E-Tag cache hit)
* ~300-500ms (E-Tag miss, needs PROPFIND)
- *
+ *
* Strategy:
* 1. Store E-Tag of /notes/ collection after each sync
* 2. HEAD request to check if E-Tag changed
@@ -299,12 +300,12 @@ class WebDavSyncService(private val context: Context) {
return try {
val startTime = System.currentTimeMillis()
val lastSyncTime = getLastSyncTimestamp()
-
+
if (lastSyncTime == 0L) {
Logger.d(TAG, "π Never synced - assuming server has changes")
return true
}
-
+
val notesUrl = getNotesUrl(serverUrl)
// π§ v1.7.2: Exception wird NICHT gefangen - muss nach oben propagieren!
// Wenn sardine.exists() timeout hat, soll hasUnsyncedChanges() das behandeln
@@ -312,40 +313,40 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "π /notes/ doesn't exist - assuming no server changes")
return false
}
-
+
// ====== JSON FILES CHECK (/notes/) ======
-
+
// β‘ v1.3.1: File-level E-Tag check in downloadRemoteNotes() is optimal!
// Collection E-Tag doesn't work (server-dependent, doesn't track file changes)
// β Always proceed to download phase where file-level E-Tags provide fast skips
-
+
// For hasUnsyncedChanges(): Conservative approach - assume changes may exist
// Actual file-level E-Tag checks in downloadRemoteNotes() will skip unchanged files (0ms each)
var hasJsonChanges = true // Assume yes, let file E-Tags optimize
-
+
// ====== MARKDOWN FILES CHECK (/notes-md/) ======
// IMPORTANT: E-Tag for collections does NOT work for content changes!
// β Use hybrid approach: If-Modified-Since + Timestamp fallback
-
+
val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
if (!markdownAutoImportEnabled) {
Logger.d(TAG, "βοΈ Markdown check skipped (auto-import disabled)")
} else {
val mdUrl = getMarkdownUrl(serverUrl)
-
+
if (!sardine.exists(mdUrl)) {
Logger.d(TAG, "π /notes-md/ doesn't exist - no markdown changes")
} else {
Logger.d(TAG, "π Checking Markdown files (hybrid approach)...")
-
+
// Strategy: Timestamp-based check (reliable, always works)
// Note: If-Modified-Since support varies by WebDAV server
// We use timestamp comparison which is universal
val mdResources = sardine.list(mdUrl, 1)
val mdHasNewer = mdResources.any { resource ->
- !resource.isDirectory &&
+ !resource.isDirectory &&
resource.name.endsWith(".md") &&
- resource.modified?.time?.let {
+ resource.modified?.time?.let {
val hasNewer = it > lastSyncTime
if (hasNewer) {
Logger.d(
@@ -357,7 +358,7 @@ class WebDavSyncService(private val context: Context) {
hasNewer
} ?: false
}
-
+
if (mdHasNewer) {
val mdCount = mdResources.count { !it.isDirectory && it.name.endsWith(".md") }
Logger.d(TAG, "π Markdown files have changes ($mdCount files checked)")
@@ -367,76 +368,75 @@ class WebDavSyncService(private val context: Context) {
}
}
}
-
+
val elapsed = System.currentTimeMillis() - startTime
-
+
// Return TRUE if JSON or Markdown have potential changes
// (File-level E-Tags will do the actual skip optimization during sync)
if (hasJsonChanges) {
Logger.d(TAG, "β
JSON may have changes - will check file E-Tags (${elapsed}ms)")
return true
}
-
+
Logger.d(TAG, "β
No changes detected (Markdown checked, ${elapsed}ms)")
return false
-
+
} catch (e: Exception) {
Logger.w(TAG, "Server check failed: ${e.message} - assuming changes exist")
true // Safe default: check anyway
}
}
-
+
/**
* PrΓΌft ob lokale Γnderungen seit letztem Sync vorhanden sind (v1.1.2)
* Performance-Optimierung: Vermeidet unnΓΆtige Sync-Operationen
- *
+ *
* @return true wenn unsynced changes vorhanden, false sonst
*/
suspend fun hasUnsyncedChanges(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
val lastSyncTime = getLastSyncTimestamp()
-
+
// Check 1: Never synced
if (lastSyncTime == 0L) {
Logger.d(TAG, "π Never synced - has changes: true")
return@withContext true
}
-
+
// Check 2: Local changes
- val storage = NotesStorage(context)
val allNotes = storage.loadAllNotes()
val hasLocalChanges = allNotes.any { note ->
note.updatedAt > lastSyncTime
}
-
+
if (hasLocalChanges) {
val unsyncedCount = allNotes.count { it.updatedAt > lastSyncTime }
Logger.d(TAG, "π Local changes: $unsyncedCount notes modified")
return@withContext true
}
-
+
// Check 3: Server changes (respects user preference)
val alwaysCheckServer = prefs.getBoolean(Constants.KEY_ALWAYS_CHECK_SERVER, true)
-
+
if (!alwaysCheckServer) {
Logger.d(TAG, "βοΈ Server check disabled by user - has changes: false")
return@withContext false
}
-
+
// Perform intelligent server check
val sardine = getOrCreateSardine()
val serverUrl = getServerUrl()
-
+
if (sardine == null || serverUrl == null) {
Logger.w(TAG, "β οΈ Cannot check server - no credentials")
return@withContext false
}
-
+
val hasServerChanges = checkServerForChanges(sardine, serverUrl)
Logger.d(TAG, "π Final check: local=$hasLocalChanges, server=$hasServerChanges")
-
+
hasServerChanges
-
+
} catch (e: Exception) {
// π§ v1.7.2 KRITISCH: Bei Server-Fehler (Timeout, etc.) return TRUE!
// Grund: Besser fΓ€lschlich synchen als "Already synced" zeigen obwohl Server nicht erreichbar
@@ -445,11 +445,11 @@ class WebDavSyncService(private val context: Context) {
true // Sicherheitshalber TRUE β Sync wird versucht und gibt dann echte Fehlermeldung
}
}
-
+
/**
* PrΓΌft ob WebDAV-Server erreichbar ist (ohne Sync zu starten)
* Verwendet Socket-Check fΓΌr schnelle ErreichbarkeitsprΓΌfung
- *
+ *
* @return true wenn Server erreichbar ist, false sonst
*/
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
@@ -459,19 +459,19 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "β Server URL not configured")
return@withContext false
}
-
+
val url = URL(serverUrl)
val host = url.host
val port = if (url.port > 0) url.port else url.defaultPort
-
+
Logger.d(TAG, "π Checking server reachability: $host:$port")
-
+
// Socket-Check mit Timeout
// Gibt dem Netzwerk Zeit fΓΌr Initialisierung (DHCP, Routing, Gateway)
val socket = Socket()
socket.connect(InetSocketAddress(host, port), SOCKET_TIMEOUT_MS)
socket.close()
-
+
Logger.d(TAG, "β
Server is reachable")
true
} catch (e: Exception) {
@@ -479,16 +479,16 @@ class WebDavSyncService(private val context: Context) {
false
}
}
-
+
/**
* π v1.7.0: PrΓΌft ob GerΓ€t aktuell im WLAN ist
* FΓΌr schnellen Pre-Check VOR dem langsamen Socket-Check
- *
+ *
* @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk)
*/
fun isOnWiFi(): Boolean {
return try {
- val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as? ConnectivityManager ?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
@@ -498,12 +498,12 @@ class WebDavSyncService(private val context: Context) {
false
}
}
-
+
/**
* π v1.7.0: Zentrale Sync-Gate PrΓΌfung
* PrΓΌft ALLE Voraussetzungen bevor ein Sync gestartet wird.
* Diese Funktion sollte VOR jedem syncNotes() Aufruf verwendet werden.
- *
+ *
* @return SyncGateResult mit canSync flag und optionalem Blockierungsgrund
*/
fun canSync(): SyncGateResult {
@@ -511,22 +511,22 @@ class WebDavSyncService(private val context: Context) {
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
}
-
+
// 2. Server configured?
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
}
-
+
// 3. WiFi-Only Check
val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
if (wifiOnlySync && !isOnWiFi()) {
return SyncGateResult(canSync = false, blockReason = "wifi_only")
}
-
+
return SyncGateResult(canSync = true, blockReason = null)
}
-
+
/**
* π v1.7.0: Result-Klasse fΓΌr canSync()
*/
@@ -536,31 +536,31 @@ class WebDavSyncService(private val context: Context) {
) {
val isBlockedByWifiOnly: Boolean get() = blockReason == "wifi_only"
}
-
+
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
)
-
+
val serverUrl = getServerUrl() ?: return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert"
)
-
+
// Only test if directory exists or can be created
val exists = sardine.exists(serverUrl)
if (!exists) {
sardine.createDirectory(serverUrl)
}
-
+
SyncResult(
isSuccess = true,
syncedCount = 0,
errorMessage = null
)
-
+
} catch (e: Exception) {
SyncResult(
isSuccess = false,
@@ -582,7 +582,7 @@ class WebDavSyncService(private val context: Context) {
)
}
}
-
+
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
// π v1.3.1: Verhindere parallele Syncs
if (!syncMutex.tryLock()) {
@@ -593,18 +593,18 @@ class WebDavSyncService(private val context: Context) {
errorMessage = null
)
}
-
+
try {
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
Logger.d(TAG, "π syncNotes() ENTRY")
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
-
+
return@withContext try {
// π v1.8.0: Banner bleibt in PREPARING bis echte Arbeit (Upload/Download) anfΓ€llt
-
+
Logger.d(TAG, "π Step 1: Getting Sardine client")
-
+
val sardine = try {
getOrCreateSardine()
} catch (e: Exception) {
@@ -612,7 +612,7 @@ class WebDavSyncService(private val context: Context) {
e.printStackTrace()
throw e
}
-
+
if (sardine == null) {
Logger.e(TAG, "β Sardine is null - credentials missing")
return@withContext SyncResult(
@@ -621,7 +621,7 @@ class WebDavSyncService(private val context: Context) {
)
}
Logger.d(TAG, " β
Sardine client created")
-
+
Logger.d(TAG, "π Step 2: Getting server URL")
val serverUrl = getServerUrl()
if (serverUrl == null) {
@@ -631,21 +631,21 @@ class WebDavSyncService(private val context: Context) {
errorMessage = "Server-URL nicht konfiguriert"
)
}
-
+
Logger.d(TAG, "π‘ Server URL: $serverUrl")
Logger.d(TAG, "π Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
-
+
var syncedCount = 0
var conflictCount = 0
-
+
Logger.d(TAG, "π Step 3: Checking server directory")
// β‘ v1.3.1: Verwende gecachte Directory-Checks
val notesUrl = getNotesUrl(serverUrl)
ensureNotesDirectoryExists(sardine, notesUrl)
-
+
// Ensure notes-md/ directory exists (for Markdown export)
ensureMarkdownDirectoryExists(sardine, serverUrl)
-
+
// π v1.8.0: Phase 2 - Uploading (Phase wird nur bei echten Uploads gesetzt)
Logger.d(TAG, "π Step 4: Uploading local notes")
// Upload local notes
@@ -670,7 +670,7 @@ class WebDavSyncService(private val context: Context) {
e.printStackTrace()
throw e
}
-
+
// π v1.8.0: Phase 3 - Downloading (Phase wird nur bei echten Downloads gesetzt)
Logger.d(TAG, "π Step 5: Downloading remote notes")
// Download remote notes
@@ -678,7 +678,7 @@ class WebDavSyncService(private val context: Context) {
try {
Logger.d(TAG, "β¬οΈ Downloading remote notes...")
val downloadResult = downloadRemoteNotes(
- sardine,
+ sardine,
serverUrl,
includeRootFallback = true, // β
v1.3.0: Enable for v1.2.0 compatibility
onProgress = { current, _, noteTitle ->
@@ -706,9 +706,9 @@ class WebDavSyncService(private val context: Context) {
e.printStackTrace()
throw e
}
-
+
Logger.d(TAG, "π Step 6: Auto-import Markdown (if enabled)")
-
+
// Auto-import Markdown files from server
var markdownImportedCount = 0
try {
@@ -716,11 +716,11 @@ class WebDavSyncService(private val context: Context) {
if (markdownAutoImportEnabled) {
// π v1.8.0: Phase nur setzen wenn Feature aktiv
SyncStateManager.updateProgress(phase = SyncPhase.IMPORTING_MARKDOWN)
-
+
Logger.d(TAG, "π₯ Auto-importing Markdown files...")
markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
Logger.d(TAG, "β
Auto-imported: $markdownImportedCount Markdown files")
-
+
// π§ v1.7.2 (IMPL_014): Re-upload notes that were updated from Markdown
if (markdownImportedCount > 0) {
Logger.d(TAG, "π€ Re-uploading notes updated from Markdown (JSON sync)...")
@@ -735,9 +735,9 @@ class WebDavSyncService(private val context: Context) {
Logger.e(TAG, "β οΈ Markdown auto-import failed (non-fatal)", e)
// Non-fatal, continue
}
-
+
Logger.d(TAG, "π Step 7: Saving sync timestamp")
-
+
// Update last sync timestamp
try {
saveLastSyncTimestamp()
@@ -747,7 +747,7 @@ class WebDavSyncService(private val context: Context) {
e.printStackTrace()
// Non-fatal, continue
}
-
+
// β
v1.3.0: Hybrid counting to prevent double-counting
// - If JSON sync occurred, it represents unique notes (JSON is source of truth)
// - If ONLY Markdown edits (no JSON), use Markdown count
@@ -756,7 +756,7 @@ class WebDavSyncService(private val context: Context) {
} else {
markdownImportedCount // Fallback: Markdown-only edits
}
-
+
Logger.d(TAG, "π Sync completed successfully: $effectiveSyncedCount notes")
if (markdownImportedCount > 0 && syncedCount > 0) {
Logger.d(TAG, "π Including $markdownImportedCount Markdown file updates")
@@ -765,21 +765,21 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "ποΈ Detected $deletedOnServerCount notes deleted on server")
}
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
-
+
// π v1.8.0: Phase 6 - Completed
SyncStateManager.updateProgress(
phase = SyncPhase.COMPLETED,
current = effectiveSyncedCount,
total = effectiveSyncedCount
)
-
+
SyncResult(
isSuccess = true,
syncedCount = effectiveSyncedCount,
conflictCount = conflictCount,
deletedOnServerCount = deletedOnServerCount // π v1.8.0
)
-
+
} catch (e: Exception) {
Logger.e(TAG, "βββββββββββββββββββββββββββββββββββββββ")
Logger.e(TAG, "π₯π₯π₯ FATAL EXCEPTION in syncNotes() π₯π₯π₯")
@@ -788,10 +788,10 @@ class WebDavSyncService(private val context: Context) {
Logger.e(TAG, "Stack trace:")
e.printStackTrace()
Logger.e(TAG, "βββββββββββββββββββββββββββββββββββββββ")
-
+
// π v1.8.0: Phase ERROR
SyncStateManager.updateProgress(phase = SyncPhase.ERROR)
-
+
SyncResult(
isSuccess = false,
errorMessage = when (e) {
@@ -820,10 +820,10 @@ class WebDavSyncService(private val context: Context) {
syncMutex.unlock()
}
}
-
- @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
+
+ @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and state management
- private fun uploadLocalNotes(
+ private suspend fun uploadLocalNotes(
sardine: Sardine,
serverUrl: String,
onProgress: (current: Int, total: Int, noteTitle: String) -> Unit = { _, _, _ -> } // π v1.8.0
@@ -831,39 +831,39 @@ class WebDavSyncService(private val context: Context) {
var uploadedCount = 0
val localNotes = storage.loadAllNotes()
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
-
+
// π v1.8.0: ZΓ€hle zu uploadende Notizen fΓΌr Progress
- val pendingNotes = localNotes.filter {
- it.syncStatus == SyncStatus.LOCAL_ONLY || it.syncStatus == SyncStatus.PENDING
+ val pendingNotes = localNotes.filter {
+ it.syncStatus == SyncStatus.LOCAL_ONLY || it.syncStatus == SyncStatus.PENDING
}
val totalToUpload = pendingNotes.size
-
+
// π§ v1.7.2 (IMPL_004): Batch E-Tag Updates fΓΌr Performance
val etagUpdates = mutableMapOf()
-
+
for (note in localNotes) {
try {
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
val notesUrl = getNotesUrl(serverUrl)
val noteUrl = "$notesUrl${note.id}.json"
-
+
// π§ v1.7.2 FIX (IMPL_015): Status VOR Serialisierung auf SYNCED setzen
// Verhindert dass Server-JSON "syncStatus": "PENDING" enthΓ€lt
val noteToUpload = note.copy(syncStatus = SyncStatus.SYNCED)
val jsonBytes = noteToUpload.toJson().toByteArray()
-
+
Logger.d(TAG, " π€ Uploading: ${note.id}.json (${note.title})")
sardine.put(noteUrl, jsonBytes, "application/json")
Logger.d(TAG, " β
Upload successful")
-
+
// Lokale Kopie auch mit SYNCED speichern
storage.saveNote(noteToUpload)
uploadedCount++
-
+
// π v1.8.0: Progress mit Notiz-Titel
onProgress(uploadedCount, totalToUpload, note.title)
-
+
// β‘ v1.3.1: Refresh E-Tag after upload to prevent re-download
// π§ v1.7.2 (IMPL_004): Sammle E-Tags fΓΌr Batch-Update
try {
@@ -879,7 +879,7 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, " β οΈ Failed to get E-Tag: ${e.message}")
etagUpdates["etag_json_${note.id}"] = null
}
-
+
// 2. Markdown-Export (NEU in v1.2.0)
// LΓ€uft NACH erfolgreichem JSON-Upload
if (markdownExportEnabled) {
@@ -899,21 +899,21 @@ class WebDavSyncService(private val context: Context) {
storage.saveNote(updatedNote)
}
}
-
+
// π§ v1.7.2 (IMPL_004): Batch-Update aller E-Tags in einer Operation
if (etagUpdates.isNotEmpty()) {
batchUpdateETags(etagUpdates)
}
-
+
return uploadedCount
}
-
+
/**
* π§ v1.7.2 (IMPL_004): Batch-Update von E-Tags
- *
+ *
* Schreibt alle E-Tags in einer einzelnen I/O-Operation statt einzeln.
* Performance-Gewinn: ~50-100ms pro Batch (statt N Γ apply())
- *
+ *
* @param updates Map von E-Tag Keys zu Values (null = remove)
*/
private fun batchUpdateETags(updates: Map) {
@@ -921,7 +921,7 @@ class WebDavSyncService(private val context: Context) {
val editor = prefs.edit()
var putCount = 0
var removeCount = 0
-
+
updates.forEach { (key, value) ->
if (value != null) {
editor.putString(key, value)
@@ -931,35 +931,35 @@ class WebDavSyncService(private val context: Context) {
removeCount++
}
}
-
+
editor.apply()
Logger.d(TAG, "β‘ Batch-updated E-Tags: $putCount saved, $removeCount removed")
} catch (e: Exception) {
Logger.e(TAG, "Failed to batch-update E-Tags", e)
}
}
-
+
/**
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
- *
+ *
* @param sardine Sardine-Client
* @param serverUrl Server-URL (notes/ Ordner)
* @param note Note zum Exportieren
*/
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
val mdUrl = getMarkdownUrl(serverUrl)
-
+
// Erstelle notes-md/ Ordner falls nicht vorhanden
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "π Created notes-md/ directory")
}
-
+
// Sanitize Filename (Task #1.2.0-12)
val baseFilename = sanitizeFilename(note.title)
var filename = "$baseFilename.md"
var noteUrl = "$mdUrl/$filename"
-
+
// PrΓΌfe ob Datei bereits existiert und von anderer Note stammt
try {
if (sardine.exists(noteUrl)) {
@@ -968,7 +968,7 @@ class WebDavSyncService(private val context: Context) {
val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL)
.find(existingContent)
val existingId = existingIdMatch?.groupValues?.get(1)
-
+
if (existingId != null && existingId != note.id) {
// Andere Note hat gleichen Titel - verwende ID-Suffix
val shortId = note.id.take(8)
@@ -981,19 +981,19 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, "β οΈ Could not check existing file: ${e.message}")
// Continue with default filename
}
-
+
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
-
+
// Upload
sardine.put(noteUrl, mdContent, "text/markdown")
}
-
+
/**
* Sanitize Filename fΓΌr sichere Dateinamen (Task #1.2.0-12)
- *
+ *
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt LΓ€nge
- *
+ *
* @param title Original-Titel
* @return Sicherer Filename
*/
@@ -1004,18 +1004,18 @@ class WebDavSyncService(private val context: Context) {
.take(MAX_FILENAME_LENGTH) // Max Zeichen (Reserve fΓΌr .md)
.trim('_', ' ') // Trim Underscores/Spaces
}
-
+
/**
* Generiert eindeutigen Markdown-Dateinamen fΓΌr eine Notiz.
* Bei Duplikaten wird die Note-ID als Suffix angehΓ€ngt.
- *
+ *
* @param note Die Notiz
* @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md)
* @return Eindeutiger Dateiname (ohne .md Extension)
*/
private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet): String {
val baseFilename = sanitizeFilename(note.title)
-
+
return if (usedFilenames.contains(baseFilename)) {
// Duplikat - hΓ€nge gekΓΌrzte ID an
val shortId = note.id.take(8)
@@ -1027,13 +1027,13 @@ class WebDavSyncService(private val context: Context) {
baseFilename
}
}
-
+
/**
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
- *
+ *
* Wird beim ersten Aktivieren der Desktop-Integration aufgerufen.
* Exportiert auch bereits synchronisierte Notizen.
- *
+ *
* @return Anzahl exportierter Notizen
*/
suspend fun exportAllNotesToMarkdown(
@@ -1043,55 +1043,55 @@ class WebDavSyncService(private val context: Context) {
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): Int = withContext(Dispatchers.IO) {
Logger.d(TAG, "π Starting initial Markdown export for all notes...")
-
+
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
-
+
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
-
+
try {
val mdUrl = getMarkdownUrl(serverUrl)
-
+
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
ensureMarkdownDirectoryExists(sardine, serverUrl)
-
+
// Hole ALLE lokalen Notizen (inklusive SYNCED)
val allNotes = storage.loadAllNotes()
val totalCount = allNotes.size
var exportedCount = 0
-
+
// Track used filenames to handle duplicates
val usedFilenames = mutableSetOf()
-
+
Logger.d(TAG, "π Found $totalCount notes to export")
-
+
allNotes.forEachIndexed { index, note ->
try {
// Progress-Callback
onProgress(index + 1, totalCount)
-
+
// Eindeutiger Filename (mit Duplikat-Handling)
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
val noteUrl = "$mdUrl/$filename"
-
+
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
-
+
// Upload (ΓΌberschreibt falls vorhanden)
sardine.put(noteUrl, mdContent, "text/markdown")
-
+
exportedCount++
Logger.d(TAG, " β
Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
-
+
} catch (e: Exception) {
Logger.e(TAG, "β Failed to export ${note.title}: ${e.message}")
// Continue mit nΓ€chster Note (keine Abbruch bei Einzelfehlern)
}
}
-
+
Logger.d(TAG, "β
Initial export completed: $exportedCount/$totalCount notes")
-
+
// β‘ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync
// This prevents re-downloading all MD files on the first manual sync after initial export
if (exportedCount > 0) {
@@ -1099,41 +1099,41 @@ class WebDavSyncService(private val context: Context) {
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
Logger.d(TAG, "πΎ Set lastSyncTimestamp after initial export (enables fast next sync)")
}
-
+
return@withContext exportedCount
} finally {
// π FIX: Connection Leak β SafeSardineWrapper explizit schlieΓen
sardine.close()
}
}
-
+
private data class DownloadResult(
val downloadedCount: Int,
val conflictCount: Int,
val deletedOnServerCount: Int = 0 // π v1.8.0
)
-
+
/**
* π v1.8.0: Erkennt Notizen, die auf dem Server gelΓΆscht wurden
* π§ v1.8.1: Safety-Guard gegen leere serverNoteIds (verhindert MassenlΓΆschung)
- *
+ *
* Keine zusΓ€tzlichen HTTP-Requests! Nutzt die bereits geladene
* serverNoteIds-Liste aus dem PROPFIND-Request.
- *
+ *
* PrΓΌft ALLE Notizen (Notes + Checklists), da beide als
* JSON in /notes/{id}.json gespeichert werden.
* NoteType (NOTE vs CHECKLIST) spielt keine Rolle fΓΌr die Detection.
- *
+ *
* @param serverNoteIds Set aller Note-IDs auf dem Server (aus PROPFIND)
* @param localNotes Alle lokalen Notizen
* @return Anzahl der als DELETED_ON_SERVER markierten Notizen
*/
- private fun detectServerDeletions(
+ suspend private fun detectServerDeletions(
serverNoteIds: Set,
localNotes: List
): Int {
val syncedNotes = localNotes.filter { it.syncStatus == SyncStatus.SYNCED }
-
+
// π§ v1.8.1 SAFETY: Wenn serverNoteIds leer ist, NIEMALS Notizen als gelΓΆscht markieren!
// Ein leeres Set bedeutet wahrscheinlich: PROPFIND fehlgeschlagen, /notes/ nicht gefunden,
// oder Netzwerkfehler β NICHT dass alle Notizen gelΓΆscht wurden.
@@ -1143,7 +1143,7 @@ class WebDavSyncService(private val context: Context) {
"localSynced=${syncedNotes.size}, localTotal=${localNotes.size}")
return 0
}
-
+
// π§ v1.8.1 SAFETY: Wenn ALLE lokalen SYNCED-Notizen als gelΓΆscht erkannt werden,
// ist das fast sicher ein Fehler (z.B. falsche Server-URL oder partieller PROPFIND).
// Maximal 50% der Notizen dΓΌrfen als gelΓΆscht markiert werden.
@@ -1154,13 +1154,13 @@ class WebDavSyncService(private val context: Context) {
"serverNoteIds=${serverNoteIds.size}. ABORTING deletion detection.")
return 0
}
-
+
// π v1.8.0 (IMPL_022): Statistik-Log fΓΌr Debugging
Logger.d(TAG, "π detectServerDeletions: " +
"serverNotes=${serverNoteIds.size}, " +
"localSynced=${syncedNotes.size}, " +
"localTotal=${localNotes.size}")
-
+
var deletedCount = 0
syncedNotes.forEach { note ->
// Nur SYNCED-Notizen prΓΌfen:
@@ -1172,20 +1172,20 @@ class WebDavSyncService(private val context: Context) {
val updatedNote = note.copy(syncStatus = SyncStatus.DELETED_ON_SERVER)
storage.saveNote(updatedNote)
deletedCount++
-
+
Logger.d(TAG, "ποΈ Note '${note.title}' (${note.id}) " +
"was deleted on server, marked as DELETED_ON_SERVER")
}
}
-
+
if (deletedCount > 0) {
Logger.d(TAG, "π Server deletion detection complete: " +
"$deletedCount of ${syncedNotes.size} synced notes deleted on server")
}
-
+
return deletedCount
}
-
+
@Suppress(
"NestedBlockDepth",
"LoopWithTooManyJumpStatements",
@@ -1194,8 +1194,8 @@ class WebDavSyncService(private val context: Context) {
)
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
// TODO: Refactor into smaller functions in v1.9.0/v2.0.0 (see LINT_DETEKT_FEHLER_BEHEBUNG_PLAN.md)
- private fun downloadRemoteNotes(
- sardine: Sardine,
+ suspend private fun downloadRemoteNotes(
+ sardine: Sardine,
serverUrl: String,
includeRootFallback: Boolean = false, // π v1.2.2: Only for restore from server
forceOverwrite: Boolean = false, // π v1.3.0: For OVERWRITE_DUPLICATES mode
@@ -1206,26 +1206,26 @@ class WebDavSyncService(private val context: Context) {
var conflictCount = 0
var skippedDeleted = 0 // NEW: Track skipped deleted notes
val processedIds = mutableSetOf() // π v1.2.2: Track already loaded notes
-
+
Logger.d(TAG, "π₯ downloadRemoteNotes() called:")
Logger.d(TAG, " includeRootFallback: $includeRootFallback")
Logger.d(TAG, " forceOverwrite: $forceOverwrite")
-
+
// Use provided deletion tracker (allows fresh tracker from restore)
var trackerModified = false
-
+
// π v1.8.0: Collect server note IDs for deletion detection
val serverNoteIds = mutableSetOf()
-
+
try {
// π PHASE 1: Download from /notes/ (new structure v1.2.1+)
val notesUrl = getNotesUrl(serverUrl)
Logger.d(TAG, "π Phase 1: Checking /notes/ at: $notesUrl")
-
+
// β‘ v1.3.1: Performance - Get last sync time for skip optimization
val lastSyncTime = getLastSyncTimestamp()
var skippedUnchanged = 0
-
+
if (sardine.exists(notesUrl)) {
Logger.d(TAG, " β
/notes/ exists, scanning...")
val resources = sardine.list(notesUrl)
@@ -1448,43 +1448,43 @@ class WebDavSyncService(private val context: Context) {
} else {
Logger.w(TAG, " β οΈ /notes/ does not exist, skipping Phase 1")
}
-
+
// π PHASE 2: BACKWARD-COMPATIBILITY - Download from Root (old structure v1.2.0)
// β οΈ ONLY for restore from server! Normal sync should NOT scan Root
if (includeRootFallback) {
val rootUrl = serverUrl.trimEnd('/')
Logger.d(TAG, "π Phase 2: Checking ROOT at: $rootUrl (v1.2.0 compat)")
-
+
try {
val rootResources = sardine.list(rootUrl)
Logger.d(TAG, " π Found ${rootResources.size} resources in ROOT")
-
+
val oldNotes = rootResources.filter { resource ->
- !resource.isDirectory &&
+ !resource.isDirectory &&
resource.name.endsWith(".json") &&
!resource.path.contains("/notes/") && // Not from /notes/ subdirectory
!resource.path.contains("/notes-md/") // Not from /notes-md/
}
-
+
Logger.d(TAG, " π Filtered to ${oldNotes.size} .json files (excluding /notes/ and /notes-md/)")
-
+
if (oldNotes.isNotEmpty()) {
Logger.w(TAG, "β οΈ Found ${oldNotes.size} notes in ROOT (old v1.2.0 structure)")
-
+
for (resource in oldNotes) {
// π§ Fix: Build full URL instead of using href directly
val noteUrl = rootUrl.trimEnd('/') + "/" + resource.name
Logger.d(TAG, " π Processing: ${resource.name} from ${resource.path}")
-
+
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue
-
+
// Skip if already loaded from /notes/
if (processedIds.contains(remoteNote.id)) {
Logger.d(TAG, " βοΈ Skipping ${remoteNote.id} (already loaded from /notes/)")
continue
}
-
+
// NEW: Check deletion tracker
if (deletionTracker.isDeleted(remoteNote.id)) {
val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
@@ -1497,10 +1497,10 @@ class WebDavSyncService(private val context: Context) {
continue
}
}
-
+
processedIds.add(remoteNote.id)
val localNote = storage.loadNote(remoteNote.id)
-
+
when {
localNote == null -> {
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
@@ -1541,50 +1541,50 @@ class WebDavSyncService(private val context: Context) {
} else {
Logger.d(TAG, "βοΈ Skipping Phase 2 (Root scan) - only enabled for restore from server")
}
-
+
} catch (e: Exception) {
Logger.e(TAG, "β downloadRemoteNotes failed", e)
}
-
+
// NEW: Save deletion tracker if modified
if (trackerModified) {
storage.saveDeletionTracker(deletionTracker)
Logger.d(TAG, "πΎ Deletion tracker updated")
}
-
+
// π v1.8.0: Server-Deletions erkennen (nach Downloads)
val allLocalNotes = storage.loadAllNotes()
val deletedOnServerCount = detectServerDeletions(serverNoteIds, allLocalNotes)
-
+
if (deletedOnServerCount > 0) {
Logger.d(TAG, "$deletedOnServerCount note(s) detected as deleted on server")
}
-
+
Logger.d(TAG, "π Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted")
return DownloadResult(downloadedCount, conflictCount, deletedOnServerCount)
}
-
+
private fun saveLastSyncTimestamp() {
val now = System.currentTimeMillis()
-
+
// β‘ v1.3.1: Simplified - file-level E-Tags cached individually in downloadRemoteNotes()
// No need for collection E-Tag (doesn't work reliably across WebDAV servers)
prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
.apply()
-
+
Logger.d(TAG, "πΎ Saved sync timestamp (file E-Tags cached individually)")
}
-
+
fun getLastSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
}
-
+
fun getLastSuccessfulSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, 0)
}
-
+
/**
* Restore all notes from server with different modes (v1.3.0)
* @param mode RestoreMode (REPLACE, MERGE, or OVERWRITE_DUPLICATES)
@@ -1599,30 +1599,30 @@ class WebDavSyncService(private val context: Context) {
errorMessage = "Server-Zugangsdaten nicht konfiguriert",
restoredCount = 0
)
-
+
val serverUrl = getServerUrl() ?: return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert",
restoredCount = 0
)
-
+
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
Logger.d(TAG, "π restoreFromServer() ENTRY")
Logger.d(TAG, "Mode: $mode")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
-
+
// β
v1.3.0 FIX: WICHTIG - Deletion Tracker bei ALLEN Modi clearen!
// Restore bedeutet: "Server ist die Quelle der Wahrheit"
// β Lokale Deletion-History ist irrelevant
Logger.d(TAG, "ποΈ Clearing deletion tracker (restore mode)")
storage.clearDeletionTracker()
-
+
// β‘ v1.3.1 FIX: Clear lastSyncTimestamp to force download ALL files
// Restore = "Server ist die Quelle" β Ignore lokale Sync-History
val previousSyncTime = getLastSyncTimestamp()
prefs.edit().putLong("last_sync_timestamp", 0).apply()
Logger.d(TAG, "π Cleared lastSyncTimestamp (was: $previousSyncTime) - will download all files")
-
+
// β‘ v1.3.1 FIX: Clear E-Tag caches to force re-download
val editor = prefs.edit()
prefs.all.keys.filter { it.startsWith("etag_json_") }.forEach { key ->
@@ -1630,11 +1630,11 @@ class WebDavSyncService(private val context: Context) {
}
editor.apply()
Logger.d(TAG, "π Cleared E-Tag caches - will re-download all files")
-
+
// Determine forceOverwrite flag
val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES)
Logger.d(TAG, "forceOverwrite: $forceOverwrite")
-
+
// Mode-specific preparation
when (mode) {
dev.dettmer.simplenotes.backup.RestoreMode.REPLACE -> {
@@ -1654,7 +1654,7 @@ class WebDavSyncService(private val context: Context) {
// β
Tracker cleared β Server notes will NOT be skipped
}
}
-
+
// π v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite
// π v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data
Logger.d(
@@ -1664,15 +1664,15 @@ class WebDavSyncService(private val context: Context) {
)
val emptyTracker = DeletionTracker() // Fresh empty tracker after clear
val result = downloadRemoteNotes(
- sardine = sardine,
+ sardine = sardine,
serverUrl = serverUrl,
includeRootFallback = true, // β
Enable backward compatibility for restore
forceOverwrite = forceOverwrite, // β
v1.3.0: Force overwrite for OVERWRITE_DUPLICATES mode
deletionTracker = emptyTracker // β
v1.3.0: Use fresh tracker to prevent skipping
)
-
+
Logger.d(TAG, "π Download result: downloaded=${result.downloadedCount}, conflicts=${result.conflictCount}")
-
+
if (result.downloadedCount == 0 && mode == dev.dettmer.simplenotes.backup.RestoreMode.REPLACE) {
Logger.w(TAG, "β οΈ No notes found on server!")
return@withContext RestoreResult(
@@ -1681,14 +1681,14 @@ class WebDavSyncService(private val context: Context) {
restoredCount = 0
)
}
-
+
// NOTE: Code that removes restored notes from deletion tracker is now REDUNDANT
// because we cleared the tracker at the start. But keep it for safety:
if (result.downloadedCount > 0) {
val deletionTracker = storage.loadDeletionTracker()
val allNotes = storage.loadAllNotes()
var trackingModified = false
-
+
allNotes.forEach { note ->
if (deletionTracker.isDeleted(note.id)) {
deletionTracker.removeDeletion(note.id)
@@ -1696,24 +1696,24 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "π Removed from deletion tracker: ${note.id} (restored from server)")
}
}
-
+
if (trackingModified) {
storage.saveDeletionTracker(deletionTracker)
Logger.d(TAG, "πΎ Updated deletion tracker after restore")
}
}
-
+
saveLastSyncTimestamp()
-
+
Logger.d(TAG, "β
Restore completed: ${result.downloadedCount} notes")
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
-
+
RestoreResult(
isSuccess = true,
errorMessage = null,
restoredCount = result.downloadedCount
)
-
+
} catch (e: Exception) {
Logger.e(TAG, "βββββββββββββββββββββββββββββββββββββββ")
Logger.e(TAG, "π₯ restoreFromServer() EXCEPTION")
@@ -1728,54 +1728,54 @@ class WebDavSyncService(private val context: Context) {
)
}
}
-
+
/**
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
- *
+ *
* Last-Write-Wins KonfliktauflΓΆsung basierend auf updatedAt Timestamp
- *
+ *
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
* @param username WebDAV Username
* @param password WebDAV Password
* @return Anzahl importierter Notizen
*/
suspend fun syncMarkdownFiles(
- serverUrl: String,
- username: String,
+ serverUrl: String,
+ username: String,
password: String
): Int = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "π Starting Markdown sync...")
-
+
val okHttpClient = OkHttpClient.Builder().build()
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
-
+
try {
val mdUrl = getMarkdownUrl(serverUrl)
-
+
// Check if notes-md/ exists
if (!sardine.exists(mdUrl)) {
Logger.d(TAG, "β οΈ notes-md/ directory not found - skipping MD import")
return@withContext 0
}
-
+
val localNotes = storage.loadAllNotes()
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
var importedCount = 0
-
+
Logger.d(TAG, "π Found ${mdResources.size} markdown files")
-
+
for (resource in mdResources) {
try {
// Download MD-File
val mdContent = sardine.get(resource.href.toString())
.bufferedReader().use { it.readText() }
-
+
// Parse zu Note
val mdNote = Note.fromMarkdown(mdContent) ?: continue
-
+
val localNote = localNotes.find { it.id == mdNote.id }
-
+
// KonfliktauflΓΆsung: Last-Write-Wins
when {
localNote == null -> {
@@ -1800,54 +1800,54 @@ class WebDavSyncService(private val context: Context) {
// Continue with other files
}
}
-
+
Logger.d(TAG, "β
Markdown sync completed: $importedCount imported")
importedCount
} finally {
// π FIX: Connection Leak β SafeSardineWrapper explizit schlieΓen
sardine.close()
}
-
+
} catch (e: Exception) {
Logger.e(TAG, "Markdown sync failed", e)
0
}
}
-
+
/**
* Auto-import Markdown files during regular sync (v1.3.0)
* Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled
- *
+ *
* β‘ v1.3.1: Performance-Optimierung - Skip unverΓ€nderte Dateien
*/
- @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
+ @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Import logic requires nested conditions for file validation and duplicate handling
- private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
+ private suspend fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
return try {
Logger.d(TAG, "π Importing Markdown files...")
-
+
val mdUrl = getMarkdownUrl(serverUrl)
-
+
// Check if notes-md/ exists
if (!sardine.exists(mdUrl)) {
Logger.d(TAG, " β οΈ notes-md/ directory not found - skipping")
return 0
}
-
+
val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") }
var importedCount = 0
var skippedCount = 0 // β‘ v1.3.1: ZΓ€hle ΓΌbersprungene Dateien
-
+
Logger.d(TAG, " π Found ${mdResources.size} markdown files")
-
+
// β‘ v1.3.1: Performance-Optimierung - Letzten Sync-Zeitpunkt holen
val lastSyncTime = getLastSyncTimestamp()
Logger.d(TAG, " π
Last sync: ${Date(lastSyncTime)}")
-
+
for (resource in mdResources) {
try {
val serverModifiedTime = resource.modified?.time ?: 0L
-
+
// β‘ v1.3.1: PERFORMANCE - Skip wenn Datei seit letztem Sync nicht geΓ€ndert wurde
// Das ist der Haupt-Performance-Fix! Spart ~500ms pro Datei bei Nextcloud.
if (lastSyncTime > 0 && serverModifiedTime <= lastSyncTime) {
@@ -1855,27 +1855,27 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, " βοΈ Skipping ${resource.name}: not modified since last sync")
continue
}
-
+
Logger.d(TAG, " π Processing: ${resource.name}, modified=${resource.modified}")
-
+
// Build full URL
val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name
-
+
// Download MD content
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
Logger.d(TAG, " Downloaded ${mdContent.length} chars")
-
+
// π§ v1.7.2 (IMPL_014): Server mtime ΓΌbergeben fΓΌr korrekte Timestamp-Sync
val mdNote = Note.fromMarkdown(mdContent, serverModifiedTime)
if (mdNote == null) {
Logger.w(TAG, " β οΈ Failed to parse ${resource.name} - fromMarkdown returned null")
continue
}
-
+
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
val localNote = storage.loadNote(mdNote.id)
if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT &&
- mdNote.content.isBlank() &&
+ mdNote.content.isBlank() &&
localNote != null && localNote.content.isNotBlank()) {
Logger.w(
TAG,
@@ -1884,14 +1884,14 @@ class WebDavSyncService(private val context: Context) {
)
continue
}
-
+
Logger.d(
TAG,
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
"updatedAt=${Date(mdNote.updatedAt)}, " +
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
)
-
+
Logger.d(
TAG,
" Local note: " + if (localNote == null) {
@@ -1901,7 +1901,7 @@ class WebDavSyncService(private val context: Context) {
"syncStatus=${localNote.syncStatus}"
}
)
-
+
// β‘ v1.3.1: Content-basierte Erkennung
// Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geΓ€ndert wurde!
// Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian)
@@ -1910,18 +1910,18 @@ class WebDavSyncService(private val context: Context) {
" Comparison: mdUpdatedAt=${mdNote.updatedAt}, " +
"localUpdated=${localNote?.updatedAt ?: 0L}"
)
-
+
// Content-Vergleich: Ist der Inhalt tatsΓ€chlich unterschiedlich?
val contentChanged = localNote != null && (
- mdNote.content != localNote.content ||
+ mdNote.content != localNote.content ||
mdNote.title != localNote.title ||
mdNote.checklistItems != localNote.checklistItems
)
-
+
if (contentChanged) {
Logger.d(TAG, " π Content differs from local!")
}
-
+
// Conflict resolution: Content-First, dann Timestamp
when {
localNote == null -> {
@@ -1981,21 +1981,21 @@ class WebDavSyncService(private val context: Context) {
// Continue with other files
}
}
-
+
// β‘ v1.3.1: Verbessertes Logging mit Skip-Count
Logger.d(TAG, " π Markdown import complete: $importedCount imported, $skippedCount skipped (unchanged)")
importedCount
-
+
} catch (e: Exception) {
Logger.e(TAG, "β Markdown import failed", e)
0
}
}
-
+
/**
* Finds a Markdown file by scanning YAML frontmatter for note ID
* Used when local note is deleted and title is unavailable
- *
+ *
* @param sardine Sardine client
* @param mdUrl Base URL of notes-md/ directory
* @param noteId The note ID to search for
@@ -2009,21 +2009,21 @@ class WebDavSyncService(private val context: Context) {
return@withContext try {
Logger.d(TAG, "π Scanning MD files for ID: $noteId")
val resources = sardine.list(mdUrl)
-
+
for (resource in resources) {
if (resource.isDirectory || !resource.name.endsWith(".md")) {
continue
}
-
+
try {
// Download MD content
val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
-
+
// Parse YAML frontmatter for ID
val idMatch = Regex("""^---\s*\n.*?id:\s*([a-f0-9-]+)""", RegexOption.DOT_MATCHES_ALL)
.find(mdContent)
-
+
if (idMatch?.groupValues?.get(1) == noteId) {
Logger.d(TAG, " β
Found MD file: ${resource.name}")
return@withContext resource.name
@@ -2033,7 +2033,7 @@ class WebDavSyncService(private val context: Context) {
// Continue with next file
}
}
-
+
Logger.w(TAG, " β No MD file found for ID: $noteId")
null
} catch (e: Exception) {
@@ -2041,14 +2041,14 @@ class WebDavSyncService(private val context: Context) {
null
}
}
-
+
/**
* Deletes a note from the server (JSON + Markdown)
* Does NOT delete from local storage!
- *
+ *
* v1.4.1: Now supports v1.2.0 compatibility mode - also checks ROOT folder
* for notes that were created before the /notes/ directory structure.
- *
+ *
* @param noteId The ID of the note to delete
* @return true if at least one file was deleted, false otherwise
*/
@@ -2056,10 +2056,10 @@ class WebDavSyncService(private val context: Context) {
return@withContext try {
val sardine = getOrCreateSardine() ?: return@withContext false
val serverUrl = getServerUrl() ?: return@withContext false
-
+
var deletedJson = false
var deletedMd = false
-
+
// v1.4.1: Try to delete JSON from /notes/ first (standard path)
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
if (sardine.exists(jsonUrl)) {
@@ -2076,12 +2076,12 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "ποΈ Deleted from server: $noteId.json (from ROOT - v1.2.0 compat)")
}
}
-
+
// Delete Markdown (v1.3.0: YAML-scan based approach)
val mdBaseUrl = getMarkdownUrl(serverUrl)
val note = storage.loadNote(noteId)
var mdFilenameToDelete: String? = null
-
+
if (note != null) {
// Fast path: Note still exists locally, use title
mdFilenameToDelete = sanitizeFilename(note.title) + ".md"
@@ -2091,7 +2091,7 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "β οΈ MD deletion: Note not found locally, scanning YAML...")
mdFilenameToDelete = findMarkdownFileByNoteId(sardine, mdBaseUrl, noteId)
}
-
+
if (mdFilenameToDelete != null) {
val mdUrl = mdBaseUrl.trimEnd('/') + "/" + mdFilenameToDelete
if (sardine.exists(mdUrl)) {
@@ -2104,12 +2104,12 @@ class WebDavSyncService(private val context: Context) {
} else {
Logger.w(TAG, "β οΈ Could not determine MD filename for note $noteId")
}
-
+
if (!deletedJson && !deletedMd) {
Logger.w(TAG, "β οΈ Note $noteId not found on server")
return@withContext false
}
-
+
// Remove from deletion tracker (was explicitly deleted from server)
val deletionTracker = storage.loadDeletionTracker()
if (deletionTracker.isDeleted(noteId)) {
@@ -2117,18 +2117,18 @@ class WebDavSyncService(private val context: Context) {
storage.saveDeletionTracker(deletionTracker)
Logger.d(TAG, "π Removed from deletion tracker: $noteId")
}
-
+
true
} catch (e: Exception) {
Logger.e(TAG, "Failed to delete note from server: $noteId", e)
false
}
}
-
+
/**
* Manual Markdown sync: Export all notes + Import all MD files
* Used by manual sync button in settings (when Auto-Sync is OFF)
- *
+ *
* @return ManualMarkdownSyncResult with export and import counts
*/
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
@@ -2137,16 +2137,16 @@ class WebDavSyncService(private val context: Context) {
?: throw SyncException(context.getString(R.string.error_sardine_client_failed))
val serverUrl = getServerUrl()
?: throw SyncException(context.getString(R.string.error_server_url_not_configured))
-
+
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
-
+
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
throw SyncException(context.getString(R.string.error_server_not_configured))
}
-
+
Logger.d(TAG, "π Manual Markdown Sync START")
-
+
// Step 1: Export alle lokalen Notizen nach Markdown
val exportedCount = exportAllNotesToMarkdown(
serverUrl = serverUrl,
@@ -2154,18 +2154,18 @@ class WebDavSyncService(private val context: Context) {
password = password
)
Logger.d(TAG, " β
Export: $exportedCount notes")
-
+
// Step 2: Import alle Server-Markdown-Dateien
val importedCount = importMarkdownFiles(sardine, serverUrl)
Logger.d(TAG, " β
Import: $importedCount notes")
-
+
Logger.d(TAG, "π Manual Markdown Sync COMPLETE: exported=$exportedCount, imported=$importedCount")
-
+
ManualMarkdownSyncResult(
exportedCount = exportedCount,
importedCount = importedCount
)
-
+
} catch (e: Exception) {
Logger.e(TAG, "β Manual Markdown Sync FAILED", e)
throw e
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
index 4319c19..81e910d 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.editor
import android.app.Application
import android.content.Context
+import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
@@ -29,67 +30,69 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.koin.java.KoinJavaComponent.inject
import java.util.UUID
+import kotlin.getValue
/**
* ViewModel for NoteEditor Compose Screen
* v1.5.0: Jetpack Compose NoteEditor Redesign
- *
+ *
* Manages note editing state including title, content, and checklist items.
*/
class NoteEditorViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
-
+
companion object {
private const val TAG = "NoteEditorViewModel"
const val ARG_NOTE_ID = "noteId"
const val ARG_NOTE_TYPE = "noteType"
}
-
- private val storage = NotesStorage(application)
- private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
-
+
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _uiState = MutableStateFlow(NoteEditorUiState())
val uiState: StateFlow = _uiState.asStateFlow()
-
+
private val _checklistItems = MutableStateFlow>(emptyList())
val checklistItems: StateFlow> = _checklistItems.asStateFlow()
-
+
// π v1.6.0: Offline Mode State
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow()
-
+
// π v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
val lastChecklistSortOption: StateFlow = _lastChecklistSortOption.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Events
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _events = MutableSharedFlow()
val events: SharedFlow = _events.asSharedFlow()
-
+
// Internal state
private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
-
+
init {
loadNote()
}
-
+
private fun loadNote() {
val noteId = savedStateHandle.get(ARG_NOTE_ID)
val noteTypeString = savedStateHandle.get(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
-
+
if (noteId != null) {
loadExistingNote(noteId)
} else {
@@ -97,7 +100,7 @@ class NoteEditorViewModel(
}
}
- private fun loadExistingNote(noteId: String) {
+ private fun loadExistingNote(noteId: String) = viewModelScope.launch{
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
@@ -114,7 +117,7 @@ class NoteEditorViewModel(
}
)
}
-
+
if (note.noteType == NoteType.CHECKLIST) {
loadChecklistData(note)
}
@@ -126,7 +129,7 @@ class NoteEditorViewModel(
note.checklistSortOption?.let { sortName ->
_lastChecklistSortOption.value = parseSortOption(sortName)
}
-
+
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
@@ -146,7 +149,7 @@ class NoteEditorViewModel(
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
-
+
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
@@ -158,7 +161,7 @@ class NoteEditorViewModel(
}
)
}
-
+
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
@@ -177,19 +180,19 @@ class NoteEditorViewModel(
ChecklistSortOption.MANUAL
}
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
fun updateTitle(title: String) {
_uiState.update { it.copy(title = title) }
}
-
+
fun updateContent(content: String) {
_uiState.update { it.copy(content = content) }
}
-
+
fun updateChecklistItemText(itemId: String, newText: String) {
_checklistItems.update { items ->
items.map { item ->
@@ -197,7 +200,7 @@ class NoteEditorViewModel(
}
}
}
-
+
/**
* π v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
@@ -243,7 +246,7 @@ class NoteEditorViewModel(
}
}
}
-
+
/**
* π v1.8.1 (IMPL_15): FΓΌgt ein neues Item nach dem angegebenen Item ein.
*
@@ -320,7 +323,7 @@ class NoteEditorViewModel(
else -> items.size
}
}
-
+
fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items ->
val filtered = items.filter { it.id != itemId }
@@ -333,7 +336,7 @@ class NoteEditorViewModel(
}
}
}
-
+
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
_checklistItems.update { items ->
val fromItem = items.getOrNull(fromIndex) ?: return@update items
@@ -355,7 +358,7 @@ class NoteEditorViewModel(
mutableList.mapIndexed { index, i -> i.copy(order = index) }
}
}
-
+
/**
* π v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewΓ€hlter Option.
* Einmalige Aktion (nicht persistiert) β User kann danach per Drag & Drop feinjustieren.
@@ -363,44 +366,44 @@ class NoteEditorViewModel(
fun sortChecklistItems(option: ChecklistSortOption) {
// Merke die Auswahl fΓΌr diesen Editor-Session
_lastChecklistSortOption.value = option
-
+
_checklistItems.update { items ->
val sorted = when (option) {
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
-
- ChecklistSortOption.ALPHABETICAL_ASC ->
+
+ ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
-
- ChecklistSortOption.ALPHABETICAL_DESC ->
+
+ ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
-
- ChecklistSortOption.UNCHECKED_FIRST ->
+
+ ChecklistSortOption.UNCHECKED_FIRST ->
items.sortedBy { it.isChecked }
-
- ChecklistSortOption.CHECKED_FIRST ->
+
+ ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
}
-
+
// Order-Werte neu zuweisen
sorted.mapIndexed { index, item -> item.copy(order = index) }
}
}
-
+
fun saveNote() {
viewModelScope.launch {
val state = _uiState.value
val title = state.title.trim()
-
+
when (currentNoteType) {
NoteType.TEXT -> {
val content = state.content.trim()
-
+
if (title.isEmpty() && content.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
-
+
val note = if (existingNote != null) {
// π v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt fΓΌr SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
@@ -422,10 +425,10 @@ class NoteEditorViewModel(
syncStatus = SyncStatus.LOCAL_ONLY
)
}
-
+
storage.saveNote(note)
}
-
+
NoteType.CHECKLIST -> {
// Filter empty items
val validItems = _checklistItems.value
@@ -438,12 +441,12 @@ class NoteEditorViewModel(
order = index
)
}
-
+
if (title.isEmpty() && validItems.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
-
+
val note = if (existingNote != null) {
// π v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt fΓΌr SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
@@ -467,11 +470,11 @@ class NoteEditorViewModel(
syncStatus = SyncStatus.LOCAL_ONLY
)
}
-
+
storage.saveNote(note)
}
}
-
+
// π v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt β NavigateBack ist ausreichend
// π v1.6.0: Trigger onSave Sync
@@ -491,7 +494,7 @@ class NoteEditorViewModel(
_events.emit(NoteEditorEvent.NavigateBack)
}
}
-
+
/**
* Delete the current note
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
@@ -501,10 +504,10 @@ class NoteEditorViewModel(
viewModelScope.launch {
existingNote?.let { note ->
val noteId = note.id
-
+
// Delete locally first
storage.deleteNote(noteId)
-
+
// Delete from server if requested
if (deleteOnServer) {
try {
@@ -538,18 +541,18 @@ class NoteEditorViewModel(
)
}
}
-
+
_events.emit(NoteEditorEvent.NavigateBack)
}
}
}
-
+
fun showDeleteConfirmation() {
viewModelScope.launch {
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
}
}
-
+
fun canDelete(): Boolean = existingNote != null
/**
@@ -564,10 +567,10 @@ class NoteEditorViewModel(
* Nur checklistItems werden aktualisiert β nicht title oder content,
* damit ungespeicherte Text-Γnderungen im Editor nicht verloren gehen.
*/
- fun reloadFromStorage() {
- val noteId = savedStateHandle.get(ARG_NOTE_ID) ?: return
+ fun reloadFromStorage() = viewModelScope.launch{
+ val noteId = savedStateHandle.get(ARG_NOTE_ID) ?: return@launch
- val freshNote = storage.loadNote(noteId) ?: return
+ val freshNote = storage.loadNote(noteId) ?: return@launch
// Nur Checklist-Items aktualisieren
if (freshNote.noteType == NoteType.CHECKLIST) {
@@ -578,7 +581,7 @@ class NoteEditorViewModel(
isChecked = it.isChecked,
order = it.order
)
- } ?: return
+ } ?: return@launch
_checklistItems.value = sortChecklistItems(freshItems)
// existingNote aktualisieren damit beim Speichern der richtige
@@ -586,16 +589,16 @@ class NoteEditorViewModel(
existingNote = freshNote
}
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// π v1.6.0: Sync Trigger - onSave
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
/**
* Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger
* v1.7.0: Uses central canSync() gate for WiFi-only check
- *
+ *
* Separate throttling (5 seconds) to prevent spam when saving multiple times
*/
private fun triggerOnSaveSync() {
@@ -604,7 +607,7 @@ class NoteEditorViewModel(
Logger.d(TAG, "βοΈ onSave sync disabled - skipping")
return
}
-
+
// π v1.7.0: Zentrale Sync-Gate PrΓΌfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
@@ -616,21 +619,21 @@ class NoteEditorViewModel(
}
return
}
-
+
// Check 2: Throttling (5 seconds) to prevent spam
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastOnSaveSyncTime
-
+
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "β³ onSave sync throttled - wait ${remainingSeconds}s")
return
}
-
+
// Update last sync time
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
-
+
// Trigger sync via WorkManager
Logger.d(TAG, "π€ Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder()
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
index fa5c6e4..f215f02 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -45,6 +46,8 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.java.KoinJavaComponent.inject
+import kotlin.getValue
/**
* Main Activity with Jetpack Compose UI
@@ -68,9 +71,8 @@ class ComposeMainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModel()
- private val prefs by lazy {
- getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
- }
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
// Phase 3: Track if coming from editor to scroll to top
private var cameFromEditor = false
@@ -309,37 +311,8 @@ class ComposeMainActivity : ComponentActivity() {
* v1.4.1: Migrates existing checklists for backwards compatibility.
*/
private fun migrateChecklistsForBackwardsCompat() {
- val migrationKey = "v1.4.1_checklist_migration_done"
+ viewModel.migrateChecklistsForBackwardsCompat()
- // Only run once
- if (prefs.getBoolean(migrationKey, false)) {
- return
- }
-
- val storage = NotesStorage(this)
- val allNotes = storage.loadAllNotes()
- val checklistsToMigrate = allNotes.filter { note ->
- note.noteType == NoteType.CHECKLIST &&
- note.content.isBlank() &&
- note.checklistItems?.isNotEmpty() == true
- }
-
- if (checklistsToMigrate.isNotEmpty()) {
- Logger.d(TAG, "π v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
-
- for (note in checklistsToMigrate) {
- val updatedNote = note.copy(
- syncStatus = SyncStatus.PENDING
- )
- storage.saveNote(updatedNote)
- Logger.d(TAG, " π Marked for re-sync: ${note.title}")
- }
-
- Logger.d(TAG, "β
v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
- }
-
- // Mark migration as done
- prefs.edit().putBoolean(migrationKey, true).apply()
}
@Deprecated("Deprecated in Java")
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
index 5cdb7b8..c5a3946 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
@@ -4,12 +4,13 @@ import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
import dev.dettmer.simplenotes.R
+import dev.dettmer.simplenotes.models.NoteType
+import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager
@@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.koin.java.KoinJavaComponent.inject
+import kotlin.getValue
/**
* ViewModel for MainActivity Compose
@@ -36,10 +39,7 @@ import kotlinx.coroutines.withContext
*
* Manages notes list, sync state, and deletion with undo.
*/
-class MainViewModel(
- private val storage: NotesStorage,
- private val prefs: SharedPreferences
-) : ViewModel() {
+class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "MainViewModel"
@@ -47,6 +47,9 @@ class MainViewModel(
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
}
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Notes State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -209,11 +212,7 @@ class MainViewModel(
private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value
- val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
- id = it.id,
- content = it.content,
-
- ) }
+ val filteredNotes = allNotes.filter { it.id !in pendingIds }
withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top
@@ -298,11 +297,11 @@ class MainViewModel(
/**
* Delete all selected notes
*/
- fun deleteSelectedNotes(deleteFromServer: Boolean) {
+ fun deleteSelectedNotes(deleteFromServer: Boolean) = viewModelScope.launch {
val selectedIds = _selectedNotes.value.toList()
val selectedNotes = _notes.value.filter { it.id in selectedIds }
- if (selectedNotes.isEmpty()) return
+ if (selectedNotes.isEmpty()) return@launch
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
@@ -357,7 +356,7 @@ class MainViewModel(
/**
* Undo deletion of multiple notes
*/
- private fun undoDeleteMultiple(notes: List) {
+ private fun undoDeleteMultiple(notes: List) = viewModelScope.launch{
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
@@ -409,7 +408,7 @@ class MainViewModel(
/**
* Confirm note deletion (from dialog or auto-delete)
*/
- fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
+ fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) = viewModelScope.launch{
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + note.id
@@ -453,7 +452,7 @@ class MainViewModel(
/**
* Undo note deletion
*/
- fun undoDelete(note: Note) {
+ fun undoDelete(note: Note) = viewModelScope.launch{
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - note.id
@@ -833,4 +832,37 @@ class MainViewModel(
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
+
+ fun migrateChecklistsForBackwardsCompat() = viewModelScope.launch{
+ val migrationKey = "v1.4.1_checklist_migration_done"
+
+ // Only run once
+ if (prefs.getBoolean(migrationKey, false)) {
+ return@launch
+ }
+
+ val allNotes = storage.loadAllNotes()
+ val checklistsToMigrate = allNotes.filter { note ->
+ note.noteType == NoteType.CHECKLIST &&
+ note.content.isBlank() &&
+ note.checklistItems?.isNotEmpty() == true
+ }
+
+ if (checklistsToMigrate.isNotEmpty()) {
+ Logger.d(TAG, "π v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
+
+ for (note in checklistsToMigrate) {
+ val updatedNote = note.copy(
+ syncStatus = SyncStatus.PENDING
+ )
+ storage.saveNote(updatedNote)
+ Logger.d(TAG, " π Marked for re-sync: ${note.title}")
+ }
+
+ Logger.d(TAG, "β
v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
+ }
+
+ // Mark migration as done
+ prefs.edit().putBoolean(migrationKey, true).apply()
+ }
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
index 58e4217..8b407b4 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.settings
import android.app.Application
import android.content.Context
+import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
@@ -26,45 +27,47 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.koin.java.KoinJavaComponent.inject
import java.net.HttpURLConnection
import java.net.URL
+import kotlin.getValue
/**
* ViewModel for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign
- *
+ *
* Manages all settings state and actions across the Settings navigation graph.
*/
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
-
+
companion object {
private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
}
-
- private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+
val backupManager = BackupManager(application)
- private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
-
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+ private val prefs: SharedPreferences by inject(SharedPreferences::class.java)
+
// π§ v1.7.0 Hotfix: Track last confirmed server URL for change detection
// This prevents false-positive "server changed" toasts during text input
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Server Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
-
+
// π v1.6.0: Separate host from prefix for better UX
// isHttps determines the prefix, serverHost is the editable part
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow = _isHttps.asStateFlow()
-
+
// Extract host part (everything after http:// or https://)
private fun extractHostFromUrl(url: String): String {
return when {
@@ -73,26 +76,26 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
else -> url
}
}
-
+
// π v1.6.0: Only the host part is editable (without protocol prefix)
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
val serverHost: StateFlow = _serverHost.asStateFlow()
-
+
// π v1.6.0: Full URL for display purposes (computed from prefix + host)
val serverUrl: StateFlow = combine(_isHttps, _serverHost) { https, host ->
val prefix = if (https) "https://" else "http://"
if (host.isEmpty()) "" else prefix + host
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
-
+
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow = _username.asStateFlow()
-
+
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow = _password.asStateFlow()
-
+
private val _serverStatus = MutableStateFlow(ServerStatus.Unknown)
val serverStatus: StateFlow = _serverStatus.asStateFlow()
-
+
// π v1.6.0: Offline Mode Toggle
// Default: true for new users (no server), false for existing users (has server config)
private val _offlineMode = MutableStateFlow(
@@ -104,35 +107,35 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
)
val offlineMode: StateFlow = _offlineMode.asStateFlow()
-
+
private fun hasExistingServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
- return !serverUrl.isNullOrEmpty() &&
- serverUrl != "http://" &&
+ return !serverUrl.isNullOrEmpty() &&
+ serverUrl != "http://" &&
serverUrl != "https://"
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Events (for Activity-level actions like dialogs, intents)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _events = MutableSharedFlow()
val events: SharedFlow = _events.asSharedFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Markdown Export Progress State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _markdownExportProgress = MutableStateFlow(null)
val markdownExportProgress: StateFlow = _markdownExportProgress.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Sync Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
val autoSyncEnabled: StateFlow = _autoSyncEnabled.asStateFlow()
-
+
private val _syncInterval = MutableStateFlow(
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
)
@@ -149,82 +152,82 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
)
val triggerOnSave: StateFlow = _triggerOnSave.asStateFlow()
-
+
private val _triggerOnResume = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
)
val triggerOnResume: StateFlow = _triggerOnResume.asStateFlow()
-
+
private val _triggerWifiConnect = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
)
val triggerWifiConnect: StateFlow = _triggerWifiConnect.asStateFlow()
-
+
private val _triggerPeriodic = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
)
val triggerPeriodic: StateFlow = _triggerPeriodic.asStateFlow()
-
+
private val _triggerBoot = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
)
val triggerBoot: StateFlow = _triggerBoot.asStateFlow()
-
+
// π v1.7.0: WiFi-Only Sync Toggle
private val _wifiOnlySync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
)
val wifiOnlySync: StateFlow = _wifiOnlySync.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Markdown Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _markdownAutoSync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
)
val markdownAutoSync: StateFlow = _markdownAutoSync.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Debug Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _fileLoggingEnabled = MutableStateFlow(
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
)
val fileLoggingEnabled: StateFlow = _fileLoggingEnabled.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// π¨ v1.7.0: Display Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow = _displayMode.asStateFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// UI State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow = _isSyncing.asStateFlow()
-
+
private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow = _isBackupInProgress.asStateFlow()
-
+
// v1.8.0: Descriptive backup status text
private val _backupStatusText = MutableStateFlow("")
val backupStatusText: StateFlow = _backupStatusText.asStateFlow()
-
+
private val _showToast = MutableSharedFlow()
val showToast: SharedFlow = _showToast.asSharedFlow()
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Server Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
/**
* v1.6.0: Set offline mode on/off
* When enabled, all network features are disabled
@@ -232,7 +235,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setOfflineMode(enabled: Boolean) {
_offlineMode.value = enabled
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
-
+
if (enabled) {
_serverStatus.value = ServerStatus.OfflineMode
} else {
@@ -240,14 +243,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
checkServerStatus()
}
}
-
+
fun updateServerUrl(url: String) {
// π v1.6.0: Deprecated - use updateServerHost instead
// This function is kept for compatibility but now delegates to updateServerHost
val host = extractHostFromUrl(url)
updateServerHost(host)
}
-
+
/**
* π v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol()
@@ -257,37 +260,37 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
*/
fun updateServerHost(host: String) {
_serverHost.value = host
-
+
// β
Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (host.isEmpty()) "" else prefix + host
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
-
+
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
// π v1.6.0: Host stays the same, only prefix changes
// π§ v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
// π§ v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
-
+
// β
Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (useHttps) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
-
+
fun updateUsername(value: String) {
_username.value = value
// π§ v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
}
-
+
fun updatePassword(value: String) {
_password.value = value
// π§ v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
}
-
+
/**
* π§ v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
* This prevents false "server changed" detection during text input
@@ -298,17 +301,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// π v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
-
+
// π v1.7.0: Detect server change ONLY against last confirmed URL
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
-
+
// β
Settings are already saved in updateServerHost/Protocol/Username/Password
// This function now ONLY handles server-change detection
-
+
// Reset sync status if server actually changed
if (serverChanged) {
viewModelScope.launch {
- val count = notesStorage.resetAllSyncStatusToPending()
+ val count = storage.resetAllSyncStatusToPending()
Logger.d(TAG, "π Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
}
@@ -318,10 +321,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "πΎ Server settings check complete (no server change detected)")
}
}
-
+
/**
* οΏ½ v1.7.0 Hotfix: Improved server change detection
- *
+ *
* Only returns true if the server URL actually changed in a meaningful way.
* Handles edge cases:
* - First setup (empty β filled) = NOT a change
@@ -336,23 +339,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "First server setup detected (no reset needed)")
return false
}
-
+
// Both empty = No change
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
return false
}
-
+
// Non-empty β Empty = Server removed (keep notes local, no reset)
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
return false
}
-
+
// Same URL = No change
if (confirmedUrl == newUrl) {
return false
}
-
+
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
val normalize = { url: String ->
url.trim()
@@ -361,20 +364,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.removeSuffix("/")
.lowercase()
}
-
+
val confirmedNormalized = normalize(confirmedUrl)
val newNormalized = normalize(newUrl)
-
+
// Check if normalized URLs differ
val changed = confirmedNormalized != newNormalized
-
+
if (changed) {
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' β '$newNormalized'")
}
-
+
return changed
}
-
+
fun testConnection() {
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
@@ -398,25 +401,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun checkServerStatus() {
// π v1.6.0: Respect offline mode first
if (_offlineMode.value) {
_serverStatus.value = ServerStatus.OfflineMode
return
}
-
+
// π v1.6.0: Check if host is configured
val serverHost = _serverHost.value
if (serverHost.isEmpty()) {
_serverStatus.value = ServerStatus.NotConfigured
return
}
-
+
// Construct full URL
val prefix = if (_isHttps.value) "https://" else "http://"
val serverUrl = prefix + serverHost
-
+
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) {
@@ -436,14 +439,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
}
}
-
+
fun syncNow() {
if (_isSyncing.value) return
viewModelScope.launch {
_isSyncing.value = true
try {
val syncService = WebDavSyncService(getApplication())
-
+
// π v1.7.0: Zentrale Sync-Gate PrΓΌfung
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
@@ -454,14 +457,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
return@launch
}
-
+
emitToast(getString(R.string.toast_syncing))
-
+
if (!syncService.hasUnsyncedChanges()) {
emitToast(getString(R.string.toast_already_synced))
return@launch
}
-
+
val result = syncService.syncNotes()
if (result.isSuccess) {
emitToast(getString(R.string.toast_sync_success, result.syncedCount))
@@ -475,15 +478,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Sync Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
fun setAutoSync(enabled: Boolean) {
_autoSyncEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
-
+
viewModelScope.launch {
if (enabled) {
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
@@ -496,7 +499,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun setSyncInterval(minutes: Long) {
_syncInterval.value = minutes
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
@@ -521,19 +524,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
// π v1.6.0: Configurable Sync Triggers Setters
-
+
fun setTriggerOnSave(enabled: Boolean) {
_triggerOnSave.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
Logger.d(TAG, "Trigger onSave: $enabled")
}
-
+
fun setTriggerOnResume(enabled: Boolean) {
_triggerOnResume.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
Logger.d(TAG, "Trigger onResume: $enabled")
}
-
+
fun setTriggerWifiConnect(enabled: Boolean) {
_triggerWifiConnect.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
@@ -542,7 +545,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
}
-
+
fun setTriggerPeriodic(enabled: Boolean) {
_triggerPeriodic.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
@@ -551,13 +554,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
Logger.d(TAG, "Trigger Periodic: $enabled")
}
-
+
fun setTriggerBoot(enabled: Boolean) {
_triggerBoot.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
Logger.d(TAG, "Trigger Boot: $enabled")
}
-
+
/**
* π v1.7.0: Set WiFi-only sync mode
* When enabled, sync only happens when connected to WiFi
@@ -567,11 +570,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
Logger.d(TAG, "π‘ WiFi-only sync: $enabled")
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Markdown Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
fun setMarkdownAutoSync(enabled: Boolean) {
if (enabled) {
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
@@ -581,21 +584,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
-
+
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
emitToast(getString(R.string.toast_configure_server_first))
// Don't enable - revert state
return@launch
}
-
+
// Check if there are notes to export
- val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
- val noteCount = noteStorage.loadAllNotes().size
-
+ val noteCount = storage.loadAllNotes().size
+
if (noteCount > 0) {
// Show progress and perform initial export
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
-
+
val syncService = WebDavSyncService(getApplication())
val exportedCount = withContext(Dispatchers.IO) {
syncService.exportAllNotesToMarkdown(
@@ -607,22 +609,22 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
)
}
-
+
// Export successful - save settings
_markdownAutoSync.value = true
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply()
-
+
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
-
+
@Suppress("MagicNumber") // UI progress delay
// Clear progress after short delay
kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null
-
+
} else {
// No notes - just enable the feature
_markdownAutoSync.value = true
@@ -632,7 +634,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.apply()
emitToast(getString(R.string.toast_markdown_enabled))
}
-
+
} catch (e: Exception) {
_markdownExportProgress.value = null
emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
@@ -651,14 +653,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun performManualMarkdownSync() {
// π v1.6.0: Block in offline mode
if (_offlineMode.value) {
Logger.d(TAG, "βοΈ Manual Markdown sync blocked: Offline mode enabled")
return
}
-
+
viewModelScope.launch {
try {
emitToast(getString(R.string.toast_markdown_syncing))
@@ -670,28 +672,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Backup Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
fun createBackup(uri: Uri, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_creating)
try {
val result = backupManager.createBackup(uri, password)
-
+
// Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.backup_progress_complete)
} else {
getString(R.string.backup_progress_failed)
}
-
+
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
_backupStatusText.value = getString(R.string.backup_progress_failed)
@@ -702,24 +704,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring)
try {
val result = backupManager.restoreBackup(uri, mode, password)
-
+
// Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.restore_progress_complete)
} else {
getString(R.string.restore_progress_failed)
}
-
+
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup from file", e)
_backupStatusText.value = getString(R.string.restore_progress_failed)
@@ -730,7 +732,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
/**
* π v1.7.0: Check if backup is encrypted and call appropriate callback
*/
@@ -753,7 +755,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
@@ -763,17 +765,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode)
}
-
+
// Phase 2: Show completion status
_backupStatusText.value = if (result.isSuccess) {
getString(R.string.restore_server_progress_complete)
} else {
getString(R.string.restore_server_progress_failed)
}
-
+
// Phase 3: Clear after delay
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
-
+
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore from server", e)
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
@@ -784,11 +786,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Debug Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
fun setFileLogging(enabled: Boolean) {
_fileLoggingEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
@@ -797,7 +799,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
}
}
-
+
fun clearLogs() {
viewModelScope.launch {
try {
@@ -808,9 +810,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
}
-
+
fun getLogFile() = Logger.getLogFile(getApplication())
-
+
/**
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
* Used for testing the post-update changelog feature
@@ -820,11 +822,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
.apply()
}
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Helper
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
/**
* Check if server is configured AND not in offline mode
* v1.6.0: Returns false if offline mode is enabled
@@ -832,16 +834,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun isServerConfigured(): Boolean {
// Offline mode takes priority
if (_offlineMode.value) return false
-
+
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
- return !serverUrl.isNullOrEmpty() &&
- serverUrl != "http://" &&
+ return !serverUrl.isNullOrEmpty() &&
+ serverUrl != "http://" &&
serverUrl != "https://"
}
-
+
/**
* π v1.7.1: Get string resources with correct app locale
- *
+ *
* AndroidViewModel uses Application context which may not have the correct locale
* applied when using per-app language settings. We need to get a Context that
* respects AppCompatDelegate.getApplicationLocales().
@@ -860,7 +862,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
return context.getString(resId)
}
-
+
private fun getString(resId: Int, vararg formatArgs: Any): String {
// Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
@@ -875,11 +877,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
return context.getString(resId, *formatArgs)
}
-
+
private suspend fun emitToast(message: String) {
_showToast.emit(message)
}
-
+
/**
* Server status states
* v1.6.0: Added OfflineMode state
@@ -892,7 +894,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
data object Reachable : ServerStatus()
data class Unreachable(val error: String?) : ServerStatus()
}
-
+
/**
* Events for Activity-level actions (dialogs, intents, etc.)
* v1.5.0: Ported from old SettingsActivity
@@ -901,7 +903,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
data object RequestBatteryOptimization : SettingsEvent()
data object RestartNetworkMonitor : SettingsEvent()
}
-
+
/**
* Progress state for Markdown export
* v1.5.0: For initial export progress dialog
@@ -911,11 +913,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val total: Int,
val isComplete: Boolean = false
)
-
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// π¨ v1.7.0: Display Mode Functions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
/**
* Set display mode (list or grid)
*/
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt
index ce6ab78..1930663 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt
@@ -1,6 +1,7 @@
package dev.dettmer.simplenotes.widget
import android.content.Context
+import android.content.SharedPreferences
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
@@ -12,6 +13,9 @@ import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import dev.dettmer.simplenotes.storage.NotesStorage
+import kotlinx.coroutines.runBlocking
+import org.koin.java.KoinJavaComponent.inject
+import kotlin.getValue
/**
* π v1.8.0: Homescreen Widget fΓΌr Notizen und Checklisten
@@ -52,10 +56,11 @@ class NoteWidget : GlanceAppWidget() {
)
)
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
- val storage = NotesStorage(context)
provideContent {
val prefs = currentState()
@@ -65,7 +70,7 @@ class NoteWidget : GlanceAppWidget() {
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
val note = noteId?.let { nId ->
- storage.loadNote(nId)
+ runBlocking { storage.loadNote(nId) }
}
GlanceTheme {
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt
index 386e8bf..bfe0d3f 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
+import org.koin.java.KoinJavaComponent.inject
/**
* π v1.8.0: ActionParameter Keys fΓΌr Widget-Interaktionen
@@ -35,6 +36,9 @@ class ToggleChecklistItemAction : ActionCallback {
private const val TAG = "ToggleChecklistItem"
}
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+
+
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
@@ -43,7 +47,6 @@ class ToggleChecklistItemAction : ActionCallback {
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
- val storage = NotesStorage(context)
val note = storage.loadNote(noteId) ?: return
val updatedItems = note.checklistItems?.map { item ->
@@ -167,11 +170,11 @@ class OpenConfigAction : ActionCallback {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
}
-
+
// Config-Activity als Reconfigure ΓΆffnen
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
val appWidgetId = glanceManager.getAppWidgetId(glanceId)
-
+
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
// π FIX: Eigener Task, damit finish() nicht die MainActivity zeigt
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt
index 33c790a..87b25b8 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt
@@ -14,6 +14,8 @@ import androidx.lifecycle.lifecycleScope
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import kotlinx.coroutines.launch
+import org.koin.java.KoinJavaComponent.inject
+import kotlin.getValue
/**
* π v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
@@ -40,6 +42,8 @@ class NoteWidgetConfigActivity : ComponentActivity() {
private var currentLockState: Boolean = false
private var currentOpacity: Float = 1.0f
+ private val storage: NotesStorage by inject(NotesStorage::class.java)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -69,13 +73,12 @@ class NoteWidgetConfigActivity : ComponentActivity() {
return
}
- val storage = NotesStorage(this)
-
// Bestehende Konfiguration laden (fΓΌr Reconfigure)
lifecycleScope.launch {
var existingNoteId: String? = null
var existingLock = false
var existingOpacity = 1.0f
+ val notes = storage.loadAllNotes()
try {
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
@@ -100,7 +103,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
setContent {
SimpleNotesTheme {
NoteWidgetConfigScreen(
- storage = storage,
+ notes = notes,
initialLock = existingLock,
initialOpacity = existingOpacity,
selectedNoteId = existingNoteId,
@@ -145,7 +148,7 @@ class NoteWidgetConfigActivity : ComponentActivity() {
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
)
setResult(RESULT_OK, resultIntent)
-
+
// π FIX: ZurΓΌck zum Homescreen statt zur MainActivity
// moveTaskToBack() bringt den Task in den Hintergrund β Homescreen wird sichtbar
if (!isTaskRoot) {
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt
index 4447515..ee9954c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt
@@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
-import dev.dettmer.simplenotes.storage.NotesStorage
import kotlin.math.roundToInt
/**
@@ -59,16 +58,16 @@ private const val NOTE_PREVIEW_MAX_LENGTH = 50
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteWidgetConfigScreen(
- storage: NotesStorage,
+ notes: List,
initialLock: Boolean = false,
initialOpacity: Float = 1.0f,
selectedNoteId: String? = null,
- onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit,
- onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null,
- onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null,
+ onNoteSelected: (String, Boolean, Float) -> Unit,
+ onSave: ((String, Boolean, Float) -> Unit)? = null,
+ onSettingsChanged: ((String?, Boolean, Float) -> Unit)? = null,
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
) {
- val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } }
+ val allNotes = remember { notes.sortedByDescending { it.updatedAt } }
var lockWidget by remember { mutableStateOf(initialLock) }
var opacity by remember { mutableFloatStateOf(initialOpacity) }
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 0e7c33b..df669e7 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -17,7 +17,7 @@ navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"
room = "2.6.1"
-ksp = "2.0.0-1.0.21"
+ksp = "2.0.21-1.0.27"
koin = "3.5.3"
[libraries]